From eaf4d46b9c2626f37bdfec157af2ca3f7f99e9c2 Mon Sep 17 00:00:00 2001 From: BookSea <65435519+BookaiCode@users.noreply.github.com> Date: Sun, 6 Apr 2025 16:52:03 +0800 Subject: [PATCH 01/12] Update README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index af1d7d8..1259b4e 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,8 @@ - [实战Arthas:常见命令与最佳实践](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247488559&idx=1&sn=4b5003cb33446ab4a6173285fe9d83d3&chksm=cf8467eaf8f3eefc033de8f63cba9f0d7b2b5eb0ccfb5209f458a9ab447367b34954f296638b#rd) +- [Maven实战](https://mp.weixin.qq.com/s/ErtWrRNzjJcR2ettUhAxsQ) + ### :bulb: 资源 ### - [精品电子书(持续更新)](/docs/md/PDF.md) From 76ec887d67808cb0e3ca90f1db14bd1b1d340d41 Mon Sep 17 00:00:00 2001 From: BookSea <65435519+BookaiCode@users.noreply.github.com> Date: Sun, 6 Apr 2025 16:52:46 +0800 Subject: [PATCH 02/12] Add files via upload --- .../Maven\345\256\236\346\210\230.md" | 672 ++++++++++++++++++ 1 file changed, 672 insertions(+) create mode 100644 "docs/md/\345\205\266\344\273\226/Maven\345\256\236\346\210\230.md" diff --git "a/docs/md/\345\205\266\344\273\226/Maven\345\256\236\346\210\230.md" "b/docs/md/\345\205\266\344\273\226/Maven\345\256\236\346\210\230.md" new file mode 100644 index 0000000..272fdb2 --- /dev/null +++ "b/docs/md/\345\205\266\344\273\226/Maven\345\256\236\346\210\230.md" @@ -0,0 +1,672 @@ +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfaaUxnw8IDuicNESzxXE0vOCwHptdhCz9B6QQtOWfGdeuNPelQbYqxxg/640?wx_fmt=png&from=appmsg) + +# Maven实战 + +## 一、Maven介绍 + +### 1.1 现存问题 + +jar包问题 + +* jar包需要在本地保存,而且在使用的时候需要将jar复制到项目中,再build才可以生效。 +* jar包的体量不小,一个项目中可能需要上百的jar的支持,这样一个项目就太大了。 +* 如果jar包的版本需要升级,需要重新去搜集新版本的jar包,重新去build,时间成本太高了。 +* 做一些功能时,可能需要因为几个,甚至十几个jar包,才能完成一个功能,都需要自己维护,甚至记住。 + +项目结构的问题 + +* 之前开发工具很多,有Eclipse,MyEclipse,IDEA,VSCode等等……不同的开发工具的项目的结构会有一些不同,多人协同开发时,就会造成冲突,甚至还需要统一开发工具。 + +整体项目的生命流程 + +* 整个项目从立项开发,到最后的发布上线到生产环境,没有一套统一的流程来控制。 + +### 1.2 Maven + +- Maven可以帮助我们更好地去管理jar包,只需要指定好jar的一些基本的标识,就可以让jar包支持我们的项目。而且Maven可以帮助咱们导入一个jar包后,自动将和它绑定好的其他jar包引入。 +- Maven可以提供一个统一的项目结构。 +- Maven也对整体项目的生命周期有响应的管理,从开始的编译、测试、打包、部署等操作,都提供了相应的支持。 +- Maven还提供了分模块开发的功能。 + +Maven是apache组织的一个顶级开源项目。 http://maven.apache.org + +## 二、Maven安装&环境变量配置 + +### 2.1 Maven的安装 + +首先下载Maven,直接去官网即可 + +在点击Download之后,需要注意看一下对JDK版本的支持。 + +Maven需要JDK的环境变量支持,一定要看一下自己又没有设置上JAVA_HOME + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfQFcQNzsTx4Ga7xianbEIPEJxnaH8ibj2HDMNwWMcn0cMe5H6NAsGRxnw/640?wx_fmt=png&from=appmsg) + +需要根据自己的环境变量,下载对应的压缩包。 + +Linux、Mac选择.tar.gz的压缩包 + +Windows选择zip的压缩包 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfUIITxI3eZkQTPlAYXSB2gKsua68braYNnyXwcQVkUva7Lr1Jv9heMg/640?wx_fmt=png&from=appmsg) + +下载好之后,得到一个压缩包。 + +解压的目录最好没有任何的中文和空格等特殊字符。推荐放到磁盘的根目录下即可。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfOWicdcoIEQibfats3LyAvORzE9D35Pb4qaNmHYrJQFlR1tRFUR2sMshg/640?wx_fmt=png&from=appmsg) + +> bin:含有mvn运行的脚本。 +> +> boot:含有类加载器框架,Maven使用这个框架来加载自己的类库。 +> +> conf:含有非常核心的settings.xml文件。 +> +> lib:含有Maven运行时需要的一些类库。 + +### 2.2 Maven的环境变量的配置 + +首先配置Maven的环境变量前,必须先查看一下JDK环境变量配置。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tftwTSkK9RXff4d6ibDHJVuOicRgWup6E85sOYtUzTOuFkpeicZCEEKq1icA/640?wx_fmt=png&from=appmsg) + +其次,查看一下前面说过的JAVA_HOME。 + +上述两点都ok的话,直接开始配置环境变量 + +* 配置MAVEN_HOME![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfIapibng6RhD67b8WVboH8vQeQQbvHMtgVx5QwhVhC0ib8vBHTC4NjNJA/640?wx_fmt=png&from=appmsg) +* 配置到path![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfl15jvKDGxlsJIVLrBdJWYZc5jW5RgJ5TQQib3aK7kvtOrFCMEnh2LNA/640?wx_fmt=png&from=appmsg) + +**配置完毕后,记得重新打开一下cmd窗口。别直接在之前的cmd窗口测试**。 + +在cmd窗口执行mvn -v + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tffGpHQds8Jk1ONP8k5pPKPtVqD4TcNGWceC4YF7k1XLW3KlLJl5z1yA/640?wx_fmt=png&from=appmsg) + +> Ps:常见错误,没有配置正确的JAVA_HOME + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfto4PQfHW3F3eZPzqqZvwjk5JAt56H8ODN1qOiauoO3Ngcic27yxNibg6w/640?wx_fmt=png&from=appmsg) + +## 三、仓库&settings.xml配置(重要) + +### 3.1 仓库 + +Maven可以帮助咱们管理jar文件,但是,jar包是需要从网上下载下来的。 + +仓库很多,有官方的中央仓库,还有国内公司的仓库,还有公司内部会搭建的私服 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfB4U2Vbqn7CkqalibtOOKC28TBANS4SjqU8hxDfLOefFuiaFAU0lBicOow/640?wx_fmt=png&from=appmsg) + +咱们后面需要配置好国内公司的一些仓库。 + +### 3.2 settings.xml配置(重要) + +在MAVEN_HOME目录下,有一个conf目录。在conf目录下就有需要修改的settings.xml文件。 + +需要修改三点内容 + +#### 3.2.1 本地仓库地址 + +默认情况下,本地仓库在C盘。 + +> Default: ${user.home}/.m2/repository + +根据配置文件中的注释,默认是仍在用户目录下的.m2目录下的repository目录中。 + +这个本地仓库会随着项目越来越多,这个仓库也会越来越大。可能会占用10多个G,甚至更多。 + +所以推荐放在系统盘之外。(如果就C盘,那就用默认的吧…………) + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfTO2L2yqCOmCnJ97t2HWPJfhR0V7CfaF6vTu2icXSyOcs68C83rgrqbQ/640?wx_fmt=png&from=appmsg) + +#### 3.2.2 配置阿里云/华为云……仓库 + +配置阿里云仓库 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfx5G3xZIOpZFDwTCbiaLIH9k5cJ5WguUceuZKyd1QEKOmu26tBOQ99dg/640?wx_fmt=png&from=appmsg) + +```xml + + + + aliyun + * + 阿里云公共仓库 + https://maven.aliyun.com/repository/public + + +``` + +华为云的仓库地址:`https://repo.huaweicloud.com/repository/maven/` + +#### 3.2.3 JDK编译版本配置 + +Maven默认采用JDK1.5的编译方式去编译项目。 +为了让Maven支持现在JDK的编译版本,可以指定一下现在采用JDK1.8 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfLKU1iabIV7BJfNXAFysiap4LfEibrHQIDnp7jA0CxMecSicOQ6UH4eza5g/640?wx_fmt=png&from=appmsg) + +```xml + + + + jdk1.8 + + true + 1.8 + + + 1.8 + 1.8 + 1.8 + + + + + + jdk1.8 + +``` + +## 四、IDEA配置Maven + +**先看老版本的,再看新版本的!!!** + +### 4.1 2019.1.3 IDEA配置Maven + +打开IDEA的初始窗口 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfMUsm8qlLPkRHIUk3V6H3oDXnXRO6giaRP2LI7Lj8HTSxFZZVykv9o5Q/640?wx_fmt=png&from=appmsg) + +右下角的Configure的位置打开settings,点开后,在左上角可以看到是Settings for New Projects + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfWJWokGI1o3ibPBKyMNlpaVVcCbvM5oQ4yzTukDgUtGz1MBZ00D9Aoqg/640?wx_fmt=png&from=appmsg) + +因为IDEA版本的原因,对Maven的版本也是有要求的。 + +比如现在的2019.1.3的IDEA版本,无法支撑3.6.1以上的Maven版本 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfD8wqEoiapGrYObGicPTibwy1JqgwkRiaN3yGycYE9oUhd6UBhkb7CBJ3QA/640?wx_fmt=png&from=appmsg) + +一定要记得,点击Apply,然后ok,确认生效。 + +### 4.2 2024.1 IDEA配置Maven + +首先一定要记住,选择Settings for new projects + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tf0nZ8wBAOQXjTPdnCk1dEyQQp8noSutzD0BrdvxgPAlFKgjvqW7PFEA/640?wx_fmt=png&from=appmsg) + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfXeNQ0sOiaHliaB2BQPsaPVCONZTno4sPgZicsiaSHms3mY3k4tvwpT23yA/640?wx_fmt=png&from=appmsg) + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfw6tDNVk4FAVLGN2xTuqLKicQ00yWq4wTXbniciaPr13vfKNRNLhG0aOIA/640?wx_fmt=png&from=appmsg) + +## 五、IDEA构建Maven项目 + +**先看老版本的,再看新版本的!!!** + +### 5.1 2019.1.3 IDEA构建Maven项目 + +点击Create New Project + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tf3KbIc02XzR0RBpxKeraYMeGfzDsYvIbLVYDwrg5uX7I5CI8DnjEhPA/640?wx_fmt=png&from=appmsg) + +next后,指定当前项目的三围,包名,项目名,版本号 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tf98IQwUdg2eBpicQ8eE0LUkwIupWwqx53YjwAlLC707qaj4FsmE9tdNw/640?wx_fmt=png&from=appmsg) + +指定好项目名和存放地址。这里对存放地址修改一下就ok。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfXEtGUPwKT9icIOicU9HYOxUTpt5tk51hDgflrjicGaHdoMQ7T50IH09xA/640?wx_fmt=png&from=appmsg) + +指定好之后,点击Finish即可。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tf7IFvQ5ptxrVLGykDWjYUGruhuaeM3UElPVljCJ6mvdfm6qR6bGtDicQ/640?wx_fmt=png&from=appmsg) + +进来后,可以看到右下角的进度条,在下载一些Maven必要的插件 + +在下载插件时,可能需要一定的时间,等插件下载好,为了确认咱们阿里云私服的配置是否生效,随便复制下面内容到当前位置。 **一定一定一定记得点击右下角的import Changes** + +```xml + + + org.springframework.boot + spring-boot-starter-web + 2.1.6.RELEASE + + +``` + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfRwqpxbWwESricHicic12RZXHmy4Hia7ibF2TmmYiaJveNlPgAptcl70y5Ktw/640?wx_fmt=png&from=appmsg) + +快速地点击右下角的进度条,查看下载的链接地址,确认一下是否是阿里云的地址 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfrMF7OXYeOhDTsFrufy9BS15GoqeDMjELyyE2Vef5CCwKeMqSlqTibtA/640?wx_fmt=png&from=appmsg) + +再次查看右侧的Maven栏,确认profiles中的JDK1.8编译版本已经生效 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfiaz54Llbic4GyqkXtzJHiaYS8OZiaYnjXl96yVSh1rXnVheVSqCCFwV9AQ/640?wx_fmt=png&from=appmsg) + +最后查看完毕后,要对Maven项目的目录结构有个了解 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfU7SRWUK6rtCxWGMzD7fn2alKo6qbDRdOOVoic8uib7n2H3ToNygPXqqg/640?wx_fmt=png&from=appmsg) + +### 5.2 2014.1 IDEA构建Maven项目 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfSvM2T5G52mabcS1kNuhkVTGduhVuVPts7QXMFeibsm5nRr5ia6FqibRicg/640?wx_fmt=png&from=appmsg) + +### 5.3 IDEA构建Maven的Web项目 + +这个新老版本是一致的!!! + +这里是先构建Maven的基础项目,然后将基础项目修改为Web项目。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tf5BbAOXRVfXGGsZiafgRiaPIUQ67VlgicaGr6ysuQOL7bYJibJuyNFvypIw/640?wx_fmt=png&from=appmsg) + +正常,构建的基础maven项目,打包的方式是jar文件。需要将当前web项目的打包方式修改为war的形式。 + +需要修改pom.xml文件指定打包方式。 + +默认情况下,这个packaging是jar的打包形式。需要指定好war的形式,一定一定一定记得import Changes + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tficicDsIPcrfznuf3LNP4IcI47wfAur5HHg1XsPkual82XUhQk74wmvtg/640?wx_fmt=png&from=appmsg) + +然后选中项目,点击左上角的file,选择Project Structure + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfUm0Ngx8UMPjic3eJWsN1DUlBlrL4laW5ViaaicTmpHdpcqicXJxicW1JT2w/640?wx_fmt=png&from=appmsg)![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfh1D90QD3Via1Y7HSz4yuYDlkLVdZBw4FAs1vAG4GQ5EEpqibPM6PiavjQ/640?wx_fmt=png&from=appmsg) + +选择左侧导航栏中的facets选项,如果你的Facets界面没有这个Web,说明之前的war没配置好!! + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfLznicInlHbZZ42I5jb3xKbeGKVT3XzV7LYHxhowdtTZTk3ryMoAyRxQ/640?wx_fmt=png&from=appmsg) + +然后点击右上角的+,追加一个web.xml文件,记得一定要放到webapp资源目录下 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfzCQyRsgTkrchqC3zRtqvoa0AGrBKC83N0UtDBkb7kMIFLSEDiaNRJXg/640?wx_fmt=png&from=appmsg) + +点击ok,就会自动生成webapp目录,以及目录下的web.xml文件 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfpLMQ8TicvFCDx3vZdFM2YZMSDls2E1znUhPno9oKQtlUlaWibgGLug1A/640?wx_fmt=png&from=appmsg) + +## 六、导入依赖jar(重要) + +创建好Maven项目之后,需要导入具体的jar包时,要通过 **坐标** 导入 + +* 每个jar都需要三个内容形成一个唯一的坐标,需要groupId + artifactId + version导入一个具体的jar。 +* 在maven项目中,只需要导入配置的坐标,Maven便会自动地去网上下载jar文件,并且添加到项目中。 + +当需要使用某个jar时,知道大概的名字,但是不会背下来具体的坐标信息,可以去一个地方搜索 + +[https://mvnrepository.com/](https://mvnrepository.com/) + +可以去这个地址搜索具体的jar包坐标 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tficM7CwAK47BfdqMfiaLg9vr2PemPUJKgEgd4z1JjV7ibjLlebqubkVgFw/640?wx_fmt=png&from=appmsg) + +进入具体的依赖内部后,选择对应的版本 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfMQjBz5yWIjZARfHlOh4nicsNKicmB1vgfLFwLicNH4f8xp7Sr5zArkgrA/640?wx_fmt=png&from=appmsg) + +找到需要导入的dependency + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tficxAWicBF3mhMPoPupZdyabHw4TrtoqZjlqU0oicj1keQVuL52N4BibN0w/640?wx_fmt=png&from=appmsg) + +复制好之后,扔到项目的pom.xml文件中 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfqMPqZpyg3lCq4WlE7QjwubYtz750X0oibf1zz0sBKBNfTYDFQicWP0QQ/640?wx_fmt=png&from=appmsg) + +如果本地仓库出现了.lastUpdated后缀的文件,可能有两个情况 + +* 这个坐标的jar文件不存在 +* 因为网络原因下载失败了 + +**这种.lastUpdated后缀的文件,会导致后续依赖下载失败,记得如果出现了依赖失败,检查坐标都没问题,并且也是走阿里云或者华为云去下载的,依然失败。记得去本地仓库看一下,是不是有.lastUpdated后缀的文件导致无法下载成功**! + +## 七、依赖的作用域 + +所谓的依赖作用域就是当前这个jar文件在什么情况下,项目会使用到。 + +这个所谓的情况,可以分成三点来聊: + +* 编译阶段 +* 测试阶段 +* 运行阶段 + +Maven中给予依赖五种作用域: + +* compile(默认作用域):编译,测试,运行都会提供当前依赖的功能 + ``` + + commons-io + commons-io + 2.11.0 + + ``` +* provided:编译,测试会提供当前依赖的功能。 一般Servlet,JSP会涉及。 + ``` + + javax.servlet + javax.servlet-api + 3.1.0 + provided + + ``` +* runtime:测试,运行会提供当前依赖的功能。一般MySQL会涉及。 + ``` + + mysql + mysql-connector-java + 8.0.28 + runtime + + ``` +* test:测试会提供当前依赖的功能。 + ``` + + junit + junit + 4.13.2 + test + + ``` +* system:不是在什么情况下用,这个比较特殊,是将一些本地仓库没有的jar文件,引入到当前项目。 + ``` + + com.oracle.database.jdbc + ojdbc10 + 19.21.0.0 + system + D:/ojdbc10-19.21.0.0.jar + + ``` + +**system,不推荐用,哪怕一些依赖,本地仓库无法下载,也别用system去引入。这种引入方式会导致后期打包还是更换了环境之后,无法使用。(后面咱们会根据maven的命令,可以将本地的jar包安装到本地仓库)** + +```shell +mvn install:install-file -Dfile=D:/ojdbc10-19.21.0.0.jar -DgroupId=laozheng -DartifactId=laozheng-oracle -Dversion=yeyeye -Dpackaging=jar +``` + +搞定后,本地仓库可以看到install的jar文件和路径 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfOOyibtIXcQr6aDJzZq82aAXjOxEdCt4JnOSP9ibdoXyZqZnRgbiaXb3jQ/640?wx_fmt=png&from=appmsg) + +然后就可以在项目中引用了。 + +```xml + + laozheng + laozheng-oracle + yeyeye + +``` + +## 八、依赖冲突 + +首先,咱们要先了解一下Maven依赖的传递特性。 + +当咱们导入一个jar包后,如果这个jar为了完成一些功能,还需要其他的jar的功能。 + +比如有A,有B,其中A依赖了B。 + +咱们只需要导入A包,B会自动被依赖过来。优点大大的: + +* 不需要刻意的去记导入A之后,还需要导入什么其他的依赖。 +* 关于某个版本的A需要哪个版本的B也不需要关注。 + +上面是优点,但是也存在着一些问题。 + +当前项目 -> A -> B(1.0.0) + +当前项目 -> C -> B(2.0.0) + +此时,当前项目会出现相同的依赖,有两个,但是版本不一样,此时就会产生依赖冲突问题。 + +一般依赖冲突会在启动或者测试项目时,直接给你甩异常。而且这个依赖不太好处理。需要解决这种依赖冲突。 + +### 8.1 就近原则 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfAHwAsN3zB1FQiaOOsG2NWVhokiblxMa4lAgRst4RKhPlM8ImYFIqgZTg/640?wx_fmt=png&from=appmsg) + +明显,当前项目通过D依赖C的路径最近,基于就近原则,会使用2.0.0的版本![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfgDhQbY8YVicDKMthNSY90LDzpEics5lQLOtiaoFSH63Ck9FTwFnFmTg9Q/640?wx_fmt=png&from=appmsg) + +### 8.2 优先声明原则 + +当出现依赖传递导致相同jar包版本不一致时,此时会根据优先声明原则来决定使用谁。![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfQB2U6TVaSwOFf4Z9t1yBPVjF73oQxky43oDXXW2yvAsHuIRibcLupzg/640?wx_fmt=png&from=appmsg)![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tf2aiaTnjicKUWmniaYooFClwVPqCQ6K4pDh9XB6SeibeKWOuu8EFVm1Mnicg/640?wx_fmt=png&from=appmsg) + +如果是你主动导入的依赖,此时会根据你最后引用的版本决定![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfibgynfqcOeQjqonNwib6BUqD6lv10lS7dTvIouNNHc57oD6bOkuzueqA/640?wx_fmt=png&from=appmsg)![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfdrsXGJjWGhsqZ8PZskP6vhtEBFBZB51O27un1YlheypSMTKgLPHl8w/640?wx_fmt=png&from=appmsg) + +### 8.3 手动排除依赖 + +可以手动的形式,在引入A依赖时,将B依赖中A依赖排除掉 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfIkU0N2CJ0FvtTVHiaoKkpqzIEteDZuicHpazb6yVxzq2eibZDiblIoML0A/640?wx_fmt=png&from=appmsg) + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfk4PBgj8OkfvAtWAlgmtkuH3ibQPoSiby6CcOwqg9HoXMcJ5ibT9iaDVKOA/640?wx_fmt=png&from=appmsg) + +```xml + + + org.springframework + spring-context + 5.3.12 + + + org.springframework + spring-beans + + + org.springframework + spring-core + + + + + org.springframework + spring-aop + 5.2.10.RELEASE + + + +``` + +### 8.4 声明依赖版本 + +可以通过dependencyManagement标签,提前声明依赖的版本。 + +dependencyManagement标签只会声明版本,不会将依赖导入,导入依赖依然需要借助dependencies + +配置完下面的内容后,再导入spring-beans、spring-core无论什么方式,都使用dependencyManagement中声明的版本。 + +```xml + + + + + org.springframework + spring-beans + 5.1.8.RELEASE + + + org.springframework + spring-core + 5.3.9 + + + +``` + +如果前面已经声明好了依赖的版本。 + +但是你在pom.xml文件中,直接引入了一个具体的版本的依赖,和dependencyManagement不一致,那么会使用你指定好的版本。这种依赖传递的版本会严格遵循dependencyManagement。 + +其次,如果基于dependencyManagement声明好了版本,在dependencies中导入依赖时,是可以不写版本号的,可以直接基于dependencyManagement中的版本导入。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfvHu5eC1D5MpTG2hnzloOnv9GGpl1RloK9U6FibzMqq9RoUZcPK2CC2g/640?wx_fmt=png&from=appmsg) + +## 九、Maven指令 + +Maven为整个项目生命周期的各个阶段,提供了各种各样的指令。 + +先了解常用的几个: + +```java +mvn clean:清空target目录。 +mvn compile:编译整个项目,生成到target +mvn test:专门针对test目录下的内容做测试 +mvn package:会将当前项目打包,jar,war。 +mvn install:将当前项目进行编译,测试,打包,并且将jar包安装到本地仓库。 +// mvn deploy:私服的位置再讲 +``` + +* compile:这里是将main目录下的内容编译,生成一个target目录,将编译后的内容全部放到target目录下,java和resources都可以称为classpath,因为编译后的内容都是放在classes目录下的。![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfJSpp1PxbT73I5wR208KzY95ZVbO55T1iaAwyLgdpcaa5z9Kz2XmYn1g/640?wx_fmt=png&from=appmsg) +* clean:就是将编译后的内容全部清除掉。 +* test:测试会优先进行编译,并且会针对test目录下以Test结尾的类中追加了@Test注解的方法运行测试,如果报错,控制台会有显示。直接Build失败。![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfhfrLlicMGbBxGwuOaCxMfVRU10fjsMmuf1c5A3IlY4hKGS3cBrHbHFw/640?wx_fmt=png&from=appmsg) +* package:将项目进行打包,但是打包会经历compile以及test,并且成功后,才会将项目打包成具体的jar或者是war。打包后的具体文件,会存放在target目录下。项目打包无法跳过编译过程的,但是可以跳过测试的过程,需要自行敲命令![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfZkLaZKNhRXT98KGMqGuJ8DNTsHS5wQq79NVk4EGFCIsSwXJv470lxg/640?wx_fmt=png&from=appmsg) + + ``` + mvn package -DskipTests + ``` + + ![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfL2EIgV737KPeGU072bZR9xO8GrefYxf7Gwwic7WHlbgE0mGDxQjg2hw/640?wx_fmt=png&from=appmsg) +* install:将当前项目做好编译,测试,打包,并且将项目安装到本地仓库。如果安装到本地仓库的是一个jar包,其他项目就可以将这个jar依赖过来使用。!![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tf972dwV8t4RDNfIgUZnYuoqHibndQoOpn2icP6r9C4XNF3rmLClFmI71Q/640?wx_fmt=png&from=appmsg)![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfmACmKxfv9X2hONWiaWmYD5DzCaHxGfHiapOWpiagbpSbZWOe0kgLpozcg/640?wx_fmt=png&from=appmsg) + +## 十、聚合工程 + +在项目打包的方式中,前面聊过jar,还有war的形式。 + +除此之外,还有一个打包的形式,叫做pom。pom就是所谓的聚合工程。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfRnHAYSvGEHZYBUTcPW6e7V1bRRNxHC7tOAgFqqPF1yMo2hsqkdPNibA/640?wx_fmt=png&from=appmsg) + +构建最外层的电商聚合工程,聚合工程不需要写任何的业务代码,它的目的就是管理其他的子工程 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tflxmTadFHbicfkk51foUU6yurOzd8YXu8ys34n30MBVUtvSawWPFibzmA/640?wx_fmt=png&from=appmsg) + +构建好聚合工程后,可以再构建子工程,流程如下。![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfic4jDiaRZ4BFcJ9ibBnMN0aTNXnCao7Fw9gvkdgjjWTArGNft6prrlLIQ/640?wx_fmt=png&from=appmsg)![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfFZ0wETqJEn3cC9kzJ0tc01wcvwZhvmT2vvmYYKwgj9kzGaOLmn9dpw/640?wx_fmt=png&from=appmsg)![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfVTPR6dKwmmib3Dz07nt3BqBlDxjprOsDO1aPV0AsQ6oRlcDUXYwg3eQ/640?wx_fmt=png&from=appmsg)![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfdQyW8via4tiaQfI7Svyp1G6czft5vH2QGdPrBwOD4ia8uCYgFCibyBwYOA/640?wx_fmt=png&from=appmsg)![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfdYIlI2bZzvicmaR4QSPZIHk1Zc3qv6wnfICL60W0EZLO4D8FATRQCPA/640?wx_fmt=png&from=appmsg) + +好处是可以在聚合工程内去管理依赖的版本。同时可以基于聚合工程做统一的多个项目的打包或者其他操作。而且拆分模块去写项目。 + +## 十一、Maven私服 + +### 11.1 Maven私服的概念 + +> * 私服是搭建在局域网的一种特殊的远程仓库,目的是代理远程仓库,让下载依赖的效率更高。 +> * 有了私服之后,使用Maven需要下载依赖时,直接请求私服下载依赖,将私服中的依赖下载到本地仓库中。如果私服中没有具体依赖,私服会去外部的远程仓库下载。 +> * 私服可以解决在业务做开发时,有一些内部的依赖,是中央仓库没有提供的,是公司开发人员自行封装的一些依赖。可以将公司自研的一些框架和依赖上传到私服中,让公司内部人员可以通过私服将这种依赖下载到本地仓库。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfTyFGbWcz9gw0JQ6zZTOUeREhrsHH3nBNDaAsaDTKcjTvedFGu1MjGA/640?wx_fmt=png&from=appmsg) + +> 搭建私服的方式非常多,Apache Archiva,Sonatype Nexus。 一般都会采用后者。 + +### 11.2 搭建Nexus私服 + +去官网下载最新的安装包。 + +http://www.sonatype.com + +但是在官网想找到Download挺麻烦的,下载的话,直接进入到下面这个地址 + +https://help.sonatype.com/en/download.html + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfuoUampibiaLav3TEhfFql6cpDic7mXYIKvicPicvT5CfGbia0uib2sSYiaHLdw/640?wx_fmt=png&from=appmsg) + +下载完毕是一个zip的压缩包,最好解压到非系统盘的位置,路径不要带 **中文和空格** !!!! + +解压后,有两个目录 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfRzw5LXg8UNIPmlkDyZX70pC2cNaDhrt8v9kLVN3SXw5F4cHTjYrxoQ/640?wx_fmt=png&from=appmsg) + +进入到nexus-3.67.1-01目录下,再进入bin目录下。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfDIMdtPiaqIkBM4MpZsU4j4s7nlG999mc05fXprvSevXRNFsQGPmkSicQ/640?wx_fmt=png&from=appmsg) + +启动时,需要基于doc窗口去运行Nexus私服,但是一定要以 **超级管理员** 的身份打开cmd。![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfhibAGud10LuzjcYoC9IwBFhAADBxRJLQJ9wXLot7sazHyAkIPSm9ibIQ/640?wx_fmt=png&from=appmsg) + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tf6qBP1s729hVllDR2Lks7dE7TdmZntnD60VTXibXnhJ4NPsYGfBkVx3A/640?wx_fmt=png&from=appmsg) + +在bin目录下执行指定,访问外网慢的话,可能需要至少9~10分钟左右甚至更多。 + +```shell +nexus.exe /run +``` + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tf9WFwk5eDMukxlFDFCSv1Fe52aPfdo7AujmgOFZXV6jHIJHewZggLCw/640?wx_fmt=png&from=appmsg) + +启动成功后,直接访问http://localhost:8081/ + +进入首页后,需要加载一小会,可以访问到首页,第一个要做的事情是登录 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfzzFh4H7ug7dHcuTMKAfeibfmjOZpujZA0L8juUgecdIb8NS75R0MVkQ/640?wx_fmt=png&from=appmsg) + +登录即可,默认用户名是admin,密码在下面图中的文件里 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfrW3vSH1uQu00skVWwOrSSROr9Jw5BUWFlH9BDMNichta7Yfial9MMOsw/640?wx_fmt=png&from=appmsg) + +登录成功后,第二步需要重新设置密码 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfQ3wiaephSxia4ic6wsz1tqx84wiazAKud6c0iafHNcIYK7jUIMQBl6HCQcA/640?wx_fmt=png&from=appmsg) + +设置私服下载依赖的权限信息 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfNID5IXYE8jZSxskyUTFgZmKkFgSAMCeFEJOsL2wCPILrvIO3la5FzA/640?wx_fmt=png&from=appmsg) + +关注前四个即可 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfj7WJYicj5WQQtGGjxKRsJO21g51FtZVXO67cut3lNOyb1agB8d82ecA/640?wx_fmt=png&from=appmsg) + +### 11.3 Nexus私服配置&下载依赖 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfaGkmzTvXrF51RT8hWvU6kpeuAziazwqhnRWF28XZpX4cniajnyTKbfsw/640?wx_fmt=png&from=appmsg) + +将私服仓库的代理,设置为国内的仓库镜像源 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfYUicOqvGgwSVoCZxeYjCibDMQhsCfUjg1o9hu6ibaJqiaJnw48VOmPaGicw/640?wx_fmt=png&from=appmsg)![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfWsIewf6mmCzsDOYr7QDLzf7jvfNCtARCNmMnOzVn6Ee16OlVHT1x5Q/640?wx_fmt=png&from=appmsg) + +配置完,拉到最下面,记得Save保存一下。 + +接下来配置好私服的地址,让项目基于私服下载依赖 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfSnJJS0IHXBMybeCeSFtXxm9WEbXBdeQ7RETvHlQFmR1kxq5tCvwic3g/640?wx_fmt=png&from=appmsg) + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfTAv5edYxXhl9g08jw32pxfic5zbRLeJKp4DDTupBZpAoLb30SfZgDuA/640?wx_fmt=png&from=appmsg) + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfSjnnuaYyNibGNSfJBLVEk40dwdaDmBdnlZWLJ90kOYJ8swhEqdLdeOw/640?wx_fmt=png&from=appmsg) + +因为初始化Nexus时,选择的是下载依赖不需要认证信息。 + +如果选择的是需要,要如何配置。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfIP3tT4mFict8wLYaU8fI3GiboR1Gd5lQFGICKH2olgxT8aHzunmunjMA/640?wx_fmt=png&from=appmsg) + +### 11.4 上传依赖到私服 + +首先在Maven私服的位置,找到release和snapshot的仓库地址 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfK4M5hq8zEbdoh0ef1UrfSHu6fbVqrUko7TWFQTuAwbLZ8jvrrtTicFQ/640?wx_fmt=png&from=appmsg) + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfk0CF8uke0ES8pwzCPZ2SKiccmgCicKRgG3TFnZ2L6vpSw6CGicYNz03Jg/640?wx_fmt=png&from=appmsg) + +然后在pom.xml文件中配置相应的信息 + +```xml + + + zjw + http://localhost:8081/repository/maven-releases/ + + + zjw + http://localhost:8081/repository/maven-snapshots/ + + +``` + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfphaibx8VciaweZF70hcuMqZibFx9vwUrtuzLy2gefcr2xheHTVOp4h9qQ/640?wx_fmt=png&from=appmsg) + +准备好之后,直接在项目右侧,点击deploy上传当前项目的jar到私服 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfSsGJueGnEbCBwI81sRBrJcXzibc0ulRhEgvokQ5heRpwjuDKczKbkoQ/640?wx_fmt=png&from=appmsg) + +上传成功后,可以在私服中找到 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfW4vfmJcSPYnEVWYr4KGZt9FoD3ry4Gfv4jyEXXia1HFoCkavwPwBFYg/640?wx_fmt=png&from=appmsg) + +其他的项目在配置没问题的情况下,就可以使用私服中的各种依赖了。 From 11463b695993a4d7230869d6ead5edc82f0b3877 Mon Sep 17 00:00:00 2001 From: zhengshuhai <1242909896@qq.com> Date: Wed, 30 Apr 2025 20:57:06 +0800 Subject: [PATCH 03/12] Mockito --- README.md | 3 +- ...345\215\212\346\227\266\351\227\264(1).md" | 532 ++++++++++++++++++ 2 files changed, 533 insertions(+), 2 deletions(-) create mode 100644 "docs/md/\345\205\266\344\273\226/\344\270\215\347\224\250Mockito\345\206\231\345\215\225\345\205\203\346\265\213\350\257\225\357\274\237\344\275\240\345\217\257\350\203\275\345\234\250\346\265\252\350\264\271\344\270\200\345\215\212\346\227\266\351\227\264(1).md" diff --git a/README.md b/README.md index 1259b4e..e1278f7 100644 --- a/README.md +++ b/README.md @@ -183,10 +183,9 @@ ### :jack_o_lantern: 其他 ### - [良心推荐!几款收藏的神级IDEA插件分享](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247488457&idx=1&sn=f771ccebb84f226e7302b89caa5c056b&chksm=cf84600cf8f3e91aab4564d91feacb8822b53a2b3a79547439d64d2c0b7b293435a1ae79f994#rd) - - [实战Arthas:常见命令与最佳实践](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247488559&idx=1&sn=4b5003cb33446ab4a6173285fe9d83d3&chksm=cf8467eaf8f3eefc033de8f63cba9f0d7b2b5eb0ccfb5209f458a9ab447367b34954f296638b#rd) - - [Maven实战](https://mp.weixin.qq.com/s/ErtWrRNzjJcR2ettUhAxsQ) +- [不用Mockito写单元测试?你可能在浪费一半时间](https://mp.weixin.qq.com/s/NICubD9Yq0pn6qwpVIznfg) ### :bulb: 资源 ### diff --git "a/docs/md/\345\205\266\344\273\226/\344\270\215\347\224\250Mockito\345\206\231\345\215\225\345\205\203\346\265\213\350\257\225\357\274\237\344\275\240\345\217\257\350\203\275\345\234\250\346\265\252\350\264\271\344\270\200\345\215\212\346\227\266\351\227\264(1).md" "b/docs/md/\345\205\266\344\273\226/\344\270\215\347\224\250Mockito\345\206\231\345\215\225\345\205\203\346\265\213\350\257\225\357\274\237\344\275\240\345\217\257\350\203\275\345\234\250\346\265\252\350\264\271\344\270\200\345\215\212\346\227\266\351\227\264(1).md" new file mode 100644 index 0000000..597c1ea --- /dev/null +++ "b/docs/md/\345\205\266\344\273\226/\344\270\215\347\224\250Mockito\345\206\231\345\215\225\345\205\203\346\265\213\350\257\225\357\274\237\344\275\240\345\217\257\350\203\275\345\234\250\346\265\252\350\264\271\344\270\200\345\215\212\346\227\266\351\227\264(1).md" @@ -0,0 +1,532 @@ +你是不是也经常在写单元测试时,被数据库连接、第三方接口这些折腾得头疼?明明只是想验证自己的业务逻辑,却不得不花半天时间处理各种外部依赖——这种体验就像是想喝杯咖啡却发现要自己种咖啡豆。 + +好在Mockito这个神器能让你的测试飞起来!它帮你模拟复杂依赖,让测试回归到代码逻辑本身。无论是验证某个方法是否被正确调用,还是模拟异常来测试程序的健壮性,Mockito 都能让测试变得专注而高效。 + +# 简介 + +Mockito是一个用于Java单元测试的mock框架,用于创建**模拟对象**(mock object)来替代真实对象,帮助开发者隔离外部依赖,从而专注于单元测试的逻辑,Mockito通常配合单元测试框架(如JUnit)使用。 + +- 官方网站:https://site.mockito.org/ +- 官方文档:https://javadoc.io/doc/org.mockito/mockito-core/latest/org/mockito/Mockito.html + +# 依赖 + +```xml + + + org.mockito + mockito-core + 4.11.0 + test + +``` + +如果使用Spring Boot Test 则不需要引入,Spring Boot Test 默认集成了 Mockito。 + +# 常见用法 + +Mockito的核心功能包括: + +- **创建mock对象**:使用`mock()`创建mock对象。 +- **打桩**:使用`when()`和`thenReturn()`等方法指定mock对象的特定方法被调用时的行为(如返回值或抛出异常)。 +- **验证行为**:使用`verify()`检查mock对象的特定方法是否被调用,参数和调用次数是否符合预期。 + +下面通过示例展开介绍Mockito的用法。 + +## 验证行为 + +Mockito 的 `verify()` 用于验证**模拟对象的方法是否按预期被调用**,包括调用次数、参数匹配等。它支持精确验证(如 `times(2)`)、最少/最多次数(`atLeast()`/`atMost()`)、未调用(`never()`)及顺序验证(结合 `InOrder`)等,确保代码执行逻辑正确。 + +```java +public class MockTest { + + @Test + public void testBasicVerification() { + List mockList = mock(List.class); + + // 模拟调用 + mockList.add("apple"); + mockList.add("banana"); + mockList.add("apple"); + mockList.add("orange"); + + // 1. 验证方法被调用【恰好一次】(默认行为) + verify(mockList).add("banana"); + + // 2. 验证方法被调用【指定次数】 + verify(mockList, times(2)).add("apple"); // 精确2次 + + // 3. 验证方法【从未调用】 + verify(mockList, never()).clear(); + + // 4. 验证【调用顺序】 + InOrder inOrder = inOrder(mockList); + inOrder.verify(mockList).add("apple"); + inOrder.verify(mockList).add("banana"); + inOrder.verify(mockList).add("apple"); + + verifyNoMoreInteractions(mockList); + } + +} +``` + +`org.mockito.Mockito`类的`mock()`方法用于创建指定类或接口的mock对象。一旦创建,mock对象就会记住所有的方法调用。之后可以选择性地验证感兴趣的方法调用。 + +- **验证单次调用**:`verify(mockList).add("banana");`→ 检查 `add("banana")` 被调用 ​​1 次​​。 + +- **验证精确次数**:`verify(mockList, times(2)).add("apple");`→ 检查 `add("apple")` 被调用 ​2 次​​。 + +- **验证禁止调用**:`verify(mockList, never()).clear();`→ 确保 `clear()` ​从未调用​​。 + +- **验证调用顺序**: + + ```java + InOrder inOrder = inOrder(mockList); + inOrder.verify(mockList).add("apple"); + inOrder.verify(mockList).add("banana"); + inOrder.verify(mockList).add("apple"); + ``` + + 严格按顺序验证调用链。 + +- **未验证的调用**:`verifyNoMoreInteractions()` 用来检查mock对象没有未验证的调用。由于`mockList.add("orange")`被调用过,但没有验证,因此最后的测试将会失败。 + +## 打桩 + +**打桩**是为模拟对象(Mock)的方法调用预设返回值或行为,使得测试代码可以**隔离外部依赖**,并控制方法的输出或异常,一旦被打桩,方法将返回指定的值,无论调用多少次。通过打桩,可以模拟数据库、网络请求等复杂或不可控的操作。 + +```java + @Test + public void testStubbing() { + // 1. 创建模拟对象 + List mockList = mock(List.class); + + // 2. 基础打桩:返回固定值 + when(mockList.get(0)).thenReturn("apple"); + assertEquals("apple", mockList.get(0)); + + // 3. 抛出异常 + when(mockList.get(1)).thenThrow(new RuntimeException("索引错误")); + assertThrows(RuntimeException.class, () -> mockList.get(1)); + + // 4. 多次调用不同返回值 + when(mockList.size()) + .thenReturn(1) + .thenReturn(2); + assertEquals(1, mockList.size()); + assertEquals(2, mockList.size()); + + // 5. 参数匹配器(如 anyInt()) + when(mockList.get(anyInt())).thenReturn("default"); + assertEquals("default", mockList.get(999)); + + // 6. Void 方法打桩(如抛出异常) + doThrow(new IllegalStateException("清空失败")).when(mockList).clear(); + assertThrows(IllegalStateException.class, mockList::clear); + } +``` + +**语法优先级**: + +- `when(...).thenX()` 适用于有返回值的方法。 +- `doX().when(mock).method()` 适用于 void 方法。 + +**参数匹配器**:使用 `any()`、`eq()` 等灵活匹配参数,但需注意​参数一致性​(不能混用具体值和匹配器)。 + +**覆盖规则**:最后一次打桩会覆盖之前的定义(例如多次对 `mock.get(0)` 打桩,以最后一次为准)。 + +**默认情况下,对于所有返回值的方法,mock对象将返回适当的默认值**。例如,对于`int`或`Integer`返回0,对于`boolean`或`Boolean`返回`false`,对于集合类型返回空集合,对于其他对象类型(例如字符串)返回`null`。 + +## 连续打桩和回调打桩 + +**连续打桩(Chained Stubbing)**:为同一个方法的连续多次调用定义不同的返回值或行为,常用于模拟多次调用时的动态响应。 + +```java + @Test + public void testChainedStubbing() { + List mockList = mock(List.class); + + // 定义连续打桩:第一次调用返回 "A",第二次返回 "B",第三次抛出异常 + when(mockList.get(0)) + .thenReturn("A") + .thenReturn("B") + .thenThrow(new RuntimeException("No more elements")); + + // 验证 + assertEquals("A", mockList.get(0)); // 第一次返回 "A" + assertEquals("B", mockList.get(0)); // 第二次返回 "B" + assertThrows(RuntimeException.class, () -> mockList.get(0)); // 第三次抛出异常 + } +``` + +超出定义的调用次数后,最后一次行为会持续生效(例如第三次后继续调用会一直抛异常)。 + +**回调打桩(Callback Stubbing)**:`thenAnswer()` 可以实现动态返回值逻辑,根据方法参数或外部条件生成响应。 + +```java + @Test + public void testChainedStubbing() { + List mockList = mock(List.class); + + // 根据参数动态返回:参数是偶数时返回 "even",奇数返回 "odd" + when(mockList.get(anyInt())).thenAnswer(invocation -> { + int index = invocation.getArgument(0); // 获取第一个参数 + return (index % 2 == 0) ? "even" : "odd"; + }); + + // 验证 + assertEquals("even", mockList.get(0)); // 0是偶数 + assertEquals("odd", mockList.get(1)); // 1是奇数 + } +``` + +- **灵活控制**:可在 `thenAnswer()` 中编写任意 Java 代码,甚至访问外部变量。 +- **参数获取**:通过 `invocation.getArgument(n)` 获取第 `n` 个参数(从 0 开始)。 + +## 参数匹配器 + +Mockito默认使用`equals()`方法验证参数值。当需要额外的灵活性时,可以使用参数匹配器。 + +参数匹配器是 Mockito 提供的一种灵活的参数验证机制,允许开发者通过匹配器来匹配方法参数,而无需指定具体值。 + +参数匹配器广泛用于 `when()` 打桩和 `verify()` 验证中。 + +```java + @Test + public void testMatchers() { + List mockList = mock(List.class); + + // 1. 通用匹配器:anyInt(), anyString() + when(mockList.get(anyInt())).thenReturn("default"); + assertEquals("default", mockList.get(999)); + + // 2. 条件匹配器:startsWith(), endsWith() + when(mockList.add(startsWith("app"))).thenReturn(true); + assertTrue(mockList.add("apple")); + assertFalse(mockList.add("banana")); + + // 3. 混合使用具体值和匹配器(必须用 eq() 包裹具体值) + when(mockList.set(eq(0), anyString())).thenReturn("old_value"); + assertEquals("old_value", mockList.set(0, "new_value")); + } +``` + +**通用匹配器** + +- **作用**:匹配任意参数或特定类型参数。 + +- **常见方法**: +- `any()`:匹配任意对象(包括 `null`)。 + +- `anyInt()`, `anyString()`, `anyList()`:匹配特定类型参数。 + +- `isNull()`, `isNotNull()`:匹配 `null` 或非 `null` 参数。 + +**条件匹配器** + +- **作用**:根据逻辑条件匹配参数。 + +- **常见方法**: + + - `eq(value)`:严格匹配具体值(等同于直接写值)。 + + - `startsWith("prefix")`:匹配以指定前缀开头的字符串。 + + - `endsWith("suffix")`, `contains("substr")`:匹配字符串后缀或子串。 + + - `argThat(condition)`:自定义条件(如集合大小、对象属性)。 + +**混合使用规则** + +- **强制要求**:若方法参数中至少有一个匹配器,则所有参数必须用匹配器。 + + 错误示例: + + ```java + // 错误:混合具体值和匹配器 + when(mock.method("value", anyInt())).thenReturn(true); + ``` + + 修复方法:将具体值用 `eq()`包裹: + + ```java + when(mock.method(eq("value"), anyInt())).thenReturn(true); + ``` + + **自定义匹配器** + +通过 `argThat()` 实现复杂条件: + +```java +// 自定义匹配器:验证集合大小大于2 +when(mockList.addAll(argThat(list -> list.size() > 2))).thenReturn(true); +assertTrue(mockList.addAll(List.of("A", "B", "C"))); +``` + +更多的内置参数匹配器参考: + +- https://javadoc.io/static/org.mockito/mockito-core/4.11.0/org/mockito/ArgumentMatchers.html + +- https://javadoc.io/static/org.mockito/mockito-core/4.11.0/org/mockito/hamcrest/MockitoHamcrest.html + +## 间谍(spy) + +`spy()` 可以创建部分真实对象的代理(保留原有行为,可选择性地对某些方法打桩),适合需要混合真实逻辑与模拟行为的场景。 + +对比 `mock()`: + +| 特性 | `mock()` | `spy()` | +| :----------: | :----------------------------------: | :------------------------: | +| **默认行为** | 所有方法返回默认值(如 `null`、`0`) | 调用真实方法,除非显式打桩 | +| **适用场景** | 完全隔离被测对象依赖 | 需保留部分真实逻辑的测试 | + +```java + @Test + public void testSpyBasic() { + // 1. 创建一个 ArrayList 的 spy 对象 + List spyList = spy(new ArrayList<>()); + + // 2. 调用真实方法 + spyList.add("apple"); + spyList.add("banana"); + + // 3. 验证真实行为 + assertEquals(2, spyList.size()); // 实际调用了 add 和 size 方法 + + // 4. 对某个方法打桩 + when(spyList.size()).thenReturn(100); + assertEquals(100, spyList.size()); // 打桩生效 + + // 5. 验证方法调用次数 + verify(spyList, times(2)).add(anyString()); // 验证 add 被调用两次 + } +``` + +当对 `spy` 对象的方法打桩时,若直接使用 `when(...)` 会触发真实方法调用,可能导致异常。 + +错误示例: + +```java +List spyList = spy(new ArrayList<>()); +// 会被真实执行,但此时列表为空,导致 IndexOutOfBoundsException +when(spyList.get(0)).thenReturn("mock-value"); +``` + +正确方式:使用 `doReturn().when()` 语法避免真实调用 + +```java + List spyList = spy(new ArrayList<>()); + // 正确:不会触发 get(0) 的真实调用 + doReturn("mock-value").when(spyList).get(0); + assertEquals("mock-value", spyList.get(0)); +``` + +**最佳实践**: + +1. **优先使用 `mock()`**:除非需要保留部分真实行为,否则优先用 `mock()` 隔离依赖。 +2. **谨慎打桩**:使用 `doReturn().when()` 替代 `when().thenReturn()`,避免意外触发真实方法。 +3. **避免复杂间谍**:不要对复杂对象(如 Spring Bean)滥用 `spy()`,可能导致测试不可控。 + +## 参数捕获(ArgumentCaptor) + +ArgumentCaptor 用于在测试中捕获方法调用时传递的参数,便于后续对参数值进行详细验证(如对象属性、集合内容等)。 + +完整示例: + +```java + @Test + public void testCaptureArgument() { + // 1. 创建 Mock 对象 + UserService mockService = mock(UserService.class); + + // 2. 调用被测试方法 + User user = new User("Alice", 30); + mockService.processUser(user); + + // 3. 创建 ArgumentCaptor + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(User.class); + + // 4. 验证方法调用并捕获参数 + verify(mockService).processUser(userCaptor.capture()); + + // 5. 获取捕获的参数并验证 + User capturedUser = userCaptor.getValue(); + assertEquals("Alice", capturedUser.getName()); + assertEquals(30, capturedUser.getAge()); + } + + @Data + static class User { + private String name; + private int age; + + public User(String name, int age) { + this.name = name; + this.age = age; + } + } + + static class UserService { + public void processUser(User user) { + // 实际业务逻辑(在测试中被 Mock) + } + } +``` + +## 静态方法Mock + +`Mockito.mockStatic(Class)` 可以创建静态类的 Mock 作用域,并在其中定义行为。 + +```java + @Test + public void testMockStaticMethod() { + // 1. 创建静态类(如 LocalDate)的 Mock 作用域 + try (MockedStatic mockedLocalDate = mockStatic(LocalDate.class)) { + + // 2. 定义静态方法 now() 的行为 + LocalDate fixedDate = LocalDate.of(2023, 10, 1); + mockedLocalDate.when(LocalDate::now).thenReturn(fixedDate); + + // 3. 验证静态方法调用 + assertEquals(fixedDate, LocalDate.now()); // 返回固定日期 + mockedLocalDate.verify(LocalDate::now); // 验证 now() 被调用 + } + + // 4. 作用域结束后,静态方法恢复原始行为 + assertNotEquals("2023-10-01", LocalDate.now().toString()); + } +``` + +**作用域限制**: + +- 静态 Mock 仅在 `try-with-resources` 或 `MockedStatic.close()` 前有效。 +- 必须关闭:确保使用 `try-with-resources` 或手动 `close()`,避免影响其他测试。 + +# 注解 + +## @Mock + +@Mock用于快速创建 Mock 对象,替代 `Mockito.mock(Class)` 方法。 + +**方式 1:通过 `MockitoJUnitRunner` 自动初始化** + +```java +// 自动初始化 @Mock 注解 +@RunWith(MockitoJUnitRunner.class) +public class MockTest { + + @Mock // 自动创建 List 的 Mock 对象 + private List mockList; + + @Test + public void testMockAnnotation() { + mockList.add("test"); + verify(mockList).add("test"); + } + + +} +``` + +**JUnit 5 适配**:需使用`@ExtendWith(MockitoExtension.class)`。 + +**方式 2:手动调用 `MockitoAnnotations.openMocks()`** + +```java +public class MockTest { + @Mock + private List mockList; + + @Before + public void init() { + MockitoAnnotations.openMocks(this); // 手动初始化 @Mock 注解 + } + + @Test + public void testMockAnnotation() { + mockList.add("test"); + verify(mockList).add("test"); + } +} +``` + +## @MockBean + +在Spring Boot 集成测试中,@MockBean用于向 ApplicationContext 注入一个Mock 对象,替换原有 Bean。适用于需要隔离外部依赖(如数据库、第三方服务)的集成测试。 + +示例场景:测试 `UserService` 时,Mock 其依赖的 `UserRepository`,避免真实数据库操作。 + +```java +@SpringBootTest // 启动 Spring 上下文 +public class UserServiceTest { + + @Autowired + private UserService userService; // 被测服务 + + @MockBean // 自动替换 Spring 容器中的 UserRepository Bean + private UserRepository userRepository; + + @Test + public void testGetUserById() { + // 1. 定义 Mock 行为 + when(userRepository.findById(1L)).thenReturn(new User("Alice")); + + // 2. 调用被测方法 + User user = userService.getUserById(1L); + + // 3. 验证结果和交互 + assertEquals("Alice", user.getName()); + verify(userRepository).findById(1L); // 确保方法被调用 + } +} +``` + +- **替换规则**:若 Spring 上下文中已存在同名 Bean,`@MockBean` 会覆盖它;若不存在,则新增 Mock Bean。 +- **多 Bean 类型冲突**:若同一类型有多个 Bean,需结合 `@Qualifier` 指定名称。 + +## @InjectMock + +- **核心功能**:自动将 `@Mock` 或 `@Spy` 创建的依赖对象注入到被测试类中,简化依赖管理。 + +- **适用场景**:单元测试中,快速构建被测试类(如 Service 层),并自动注入其依赖的 Mock 对象(如 Repository)。 + +示例场景:测试 `UserService`,其依赖 `UserRepository`(需要 Mock)。 + +```java +@ExtendWith(MockitoExtension.class) +public class MockTest { + + + @Mock // 创建 UserRepository 的 Mock 对象 + private UserRepository userRepository; + + @InjectMocks // 自动将 userRepository 注入 UserService + private UserService userService; + + @Test + public void testGetUserById() { + // 1. 定义 Mock 行为 + when(userRepository.findById(1L)).thenReturn(new User("Alice")); + + // 2. 调用被测试方法 + User user = userService.getUserById(1L); + + // 3. 验证结果和交互 + assertEquals("Alice", user.getName()); + verify(userRepository).findById(1L); // 确保方法被调用 + } +} +``` + +`@InjectMocks` 按以下顺序尝试注入依赖: + +1. **构造函数注入**(优先选择参数最多的构造函数)。 +2. **Setter 方法注入**(按方法名匹配,如 `setUserRepository()`)。 +3. **字段注入**(直接注入到 `private` 字段,需匹配名称和类型)。 + +# 结尾 + +Mockito 的魅力在于它用简单的语法解决了测试中的复杂问题。通过模拟对象、打桩预设行为、验证调用细节,开发者可以轻松隔离外部依赖,像搭积木一样构造测试场景。无论是新手还是经验丰富的工程师,Mockito 的直观设计都能让人快速上手。 + +下次当你面对一个难以测试的方法时,不妨试试 Mockito——让它帮你把“不确定”变成“可控”,把“复杂依赖”变成“精准验证”。毕竟,好的测试不是为了证明代码完美,而是为了让它足够可靠,而 Mockito 正是这条路上值得信赖的工具。 From 535dd5b7877127e91ff8a916c3cdad47b94ce0ba Mon Sep 17 00:00:00 2001 From: zhengshuhai <1242909896@qq.com> Date: Sun, 3 Aug 2025 16:29:07 +0800 Subject: [PATCH 04/12] ANTLR --- README.md | 2 +- ...30\347\272\247\345\272\224\347\224\250.md" | 872 ++++++++++++++++++ 2 files changed, 873 insertions(+), 1 deletion(-) create mode 100644 "docs/md/\347\274\226\347\250\213\350\257\255\350\250\200/\350\207\252\347\240\224 DSL \347\245\236\345\231\250\357\274\232\344\270\207\345\255\227\346\213\206\350\247\243 ANTLR 4 \346\240\270\345\277\203\345\216\237\347\220\206\344\270\216\351\253\230\347\272\247\345\272\224\347\224\250.md" diff --git a/README.md b/README.md index e1278f7..c09df6c 100644 --- a/README.md +++ b/README.md @@ -141,8 +141,8 @@ ### :dash: 编程语言 ### - [Scala语言入门:初学者的基础语法指南](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247487245&idx=1&sn=d089e22890f1f7449b7cf34e3cf2f6ed&chksm=cf847cc8f8f3f5deb39556f4229bafb6f1498906dc1d75040f90817bf0396117a7c2cdb498f9#rd) - - [Groovy 初学者指南](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247487066&idx=1&sn=da9e3a9aff377d383e34e537e2f55666&chksm=cf847d9ff8f3f489011f26a784302ee68b9c1d7d57d52bc2c924a7c9b1a5f528ef2a417114c0#rd) +- [自研 DSL 神器:万字拆解 ANTLR 4 核心原理与高级应用]() ### :satellite: 设计模式 ### diff --git "a/docs/md/\347\274\226\347\250\213\350\257\255\350\250\200/\350\207\252\347\240\224 DSL \347\245\236\345\231\250\357\274\232\344\270\207\345\255\227\346\213\206\350\247\243 ANTLR 4 \346\240\270\345\277\203\345\216\237\347\220\206\344\270\216\351\253\230\347\272\247\345\272\224\347\224\250.md" "b/docs/md/\347\274\226\347\250\213\350\257\255\350\250\200/\350\207\252\347\240\224 DSL \347\245\236\345\231\250\357\274\232\344\270\207\345\255\227\346\213\206\350\247\243 ANTLR 4 \346\240\270\345\277\203\345\216\237\347\220\206\344\270\216\351\253\230\347\272\247\345\272\224\347\224\250.md" new file mode 100644 index 0000000..3afc345 --- /dev/null +++ "b/docs/md/\347\274\226\347\250\213\350\257\255\350\250\200/\350\207\252\347\240\224 DSL \347\245\236\345\231\250\357\274\232\344\270\207\345\255\227\346\213\206\350\247\243 ANTLR 4 \346\240\270\345\277\203\345\216\237\347\220\206\344\270\216\351\253\230\347\272\247\345\272\224\347\224\250.md" @@ -0,0 +1,872 @@ +DSL(领域特定语言) 是一种为解决特定领域的问题而专门设计的计算机语言,它不同于通用编程语言(如 Python、Java)。它通常具有高度定制化的语法和结构,聚焦于某个特定任务或领域(如数据库查询、硬件配置、报表生成),通过提供更简洁、直观且贴近领域术语的表达方式,大幅提升该领域人员的工作效率和生产力,降低复杂性。 + +**通俗来说,DSL 就像是为某个专业领域量身定做的“行话”工具。** + +说到构建自定义 DSL,高效且灵活的语法解析至关重要,**ANTLR 正是解决这一核心挑战的利器。** + +# 简介 + +- 官方地址:https://www.antlr.org/ +- GitHub:https://github.com/antlr/antlr4 +- 在线调试:http://lab.antlr.org/ +- IDEA插件:ANTLR V4 + +ANTLR 4(**AN**other **T**ool for **L**anguage **R**ecognition,版本4)是一个开源的解析器生成器工具,用于构建语言识别程序。它能够根据用户定义的语法规则,自动生成词法分析器(Lexer)和语法分析器(Parser),从而实现对结构化文本(如编程语言、配置文件、数据格式等)的解析、转换或翻译。 + +ANTLR 4 最大的核心价值就是降低语言处理的门槛。在ANTRL 4没有出现之前,语言处理主要依赖正则表达式、手工编写解析器以及早期的解析器生成工具(如Lex/Yacc)。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNFp93h7jYGKGDcTibRGQpAlUShfOl7emPyR2HvcwwYKDLAWTBTzEUv9pkSKcDmsZGDeK2BhPAhsPA/640?wx_fmt=png&from=appmsg) + +ANTLR 4 的使用很简单,因为其存在的本身的意义就是为了加快语言类应用程序的编写速度,就是为了非专业人员对语言类应用程序快速开发而生的。 + +首先我们要进行ANTLR 4元语言的编写,也就是需要我们根据我们自己的需要来编写一份语法文件,一份后缀为 **.g4** 的文件,这份文件是我们构建ANTLR 4语言类应用程序的基础,目前ANTLR 4已经支持了数十种编程语言的生成,可以满足不同语言的开发需求。 + +官方也提供了相关的文件,GitHub:https://github.com/antlr/grammars-v4。 + +有了这些 Java 文件,语言类应用程序的开发人员就不需要再去思考如何手动编写解析语法树的程序,因为ANTLR 4已经帮我们把这些事情都做了,ANTLR 4自带的jar 包和自动生成的这些语法分析器以及之后所提到的监听器 Listener 和访问器 Visitor 都能够完美的帮我们来处理任何语言类应用程序的自定义需求,从而真正达到即使你没学过编译原理也能自己开发应用程序的效果。 + +ANTLR 是用 Java 编写的,因此你需要首先安装 Java,哪怕你的目标是使用 ANTLR 来生成其他语言(如C#和C++)的解析器。 + +下图是我使用 IDEA 中的 ANTLR 4 插件,以及我自己编写的语法,自动生成的语法解析树,这一切都是ANTLR 4帮我们自动完成的。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNFp93h7jYGKGDcTibRGQpAlgibU3cuJ4l8JecpiblPtQ5HCFTJSIDicad9RHCM8K9aIia78S39ZJ0tAfg/640?wx_fmt=png&from=appmsg) + + + +简而言之,ANTLR 工具将语法文件转换成可以识别该语法文件所描述的语言的程序。例如,给定一个识别 JSON 的语法,ANTLR工具将会根据该语法生成一个程序,此程序可以通过 ANTLR 运行库来识别输入的 JSON。 + +# 基础概念 + +## 文件声明 + +以下是一个包含完整头部声明的 ANTLR 4 语法文件示例,涵盖所有关键字的解释: + +```java +// =========== ANTLR4 语法文件头部声明示例 =========== +grammar MathParser; // [1] 主声明 + +// [2] 导入声明(组合语法) +import TrigParser, VectorParser; // 导入其他语法模块 + +// [3] 选项配置 +options { + language = Java; // 目标生成语言 + tokenVocab = CoreTokens; // 从外部语法导入词法符号 + superClass = MathBase; // 自定义基类 + contextSuperClass = MyCtx; // 自定义上下文基类 +} + +// [4] 辅助符号声明 +tokens { + // 显式定义新token + PI = 'π'; // 带字面量的token + FUNCTION_CALL, // 无字面量的抽象token + VECTOR_DOT_PRODUCT // 用于语法树节点的标签 +} + +// [5] 头部注入 (生成文件顶部的代码) +@header { + package com.company.math; + import static com.company.math.TrigUtil.*; +} + +// [6] 成员注入 (向解析器类添加代码) +@members { + private boolean debug = true; + private int errorCount = 0; + + @Override + public void reportError(RecognitionException e) { + errorCount++; + super.reportError(e); + } + + public int getErrorCount() { + return errorCount; + } +} + +// [7] 规则定义区 +expression: /* 规则内容 */; +// ======================================== +``` + +- **grammar**:定义语法名称(必须匹配文件名),声明完整/词法/解析语法类型。 +- **import**:导入外部语法文件实现规则复用,支持模块化开发。语法导入允许你将语法分解成可复用的逻辑单元。ANTLR 处理被导入的语法的方式和面向对象语言中的父类非常相似。一个语法会从其导入的语法中继承所有的规则、词法符号声明和具名的动作。位于“主语法”中的规则将会覆盖其导入的语法中的规则,以此来实现继承机制。ANTLR将被导入的规则放置在主语法的词法规则列表末尾。这意味着,主语法中的词法规则具有比被导入语法中的规则更高的优先级。 +- **options**:配置代码生成选项(目标语言/基类/符号表等)。 +- **tokens**:声明辅助符号(抽象Token/别名/语法树标签)。tokens 区域存在的意义在于,它定义了一份语法所需,但却未在本语法中列出对应规则的词法符号。大多数情况下,tokens 区域用于定义本语法中动作所需的词法符号类型。 +- **@header**:向生成文件顶部注入代码(包声明/导入语句)。用于将代码注入生成的识别类中的类声明之前。用于将代码注入为识别类的字段和方法。 +- **@members**:向解析器类添加自定义成员(字段/方法/状态管理)。 + +关于 @header 和 @members,其中 @header 用于当 ANTLR 4 工具生成词法分析器和语法分析器时,将 @header 中的内容原封不动的复制到生成的 Java 文件的顶部,而 @members 用于将代码插入到生成的 Java 类当中,其中可以包含字段声明,自定义方法等内容。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNFp93h7jYGKGDcTibRGQpAlib6guJtUic38icNMVT7yAq9ia9Ao64X14cY4nZiayEVBmRLl7NNPPQtfYUg/640?wx_fmt=png&from=appmsg) + +从图中我们可以看到我们预先在语法文件中进行了 @header 和 @members 的定义和编写,然后利用 ANTLR 4 工具自动生成我们所需要的词法解析器和语法分析器等相关的 Java 文件,后续生成的这些 Java 文件中的相关位置包含了我们在 @header 和 @members 中所定义的相关内容。 + +不带前缀的语法声明是混合语法,可以同时包含词法规则和语法规则。欲创建一份只允许语法规则出现的文件,使用如下声明: + +```java +parser grammar Name; +``` + +同理,纯词法的文件如下所示: + +```java +lexer grammar Name; +``` + +## 词法规则 + +词法文件的规则以大写字母开头。 + +将字符聚集为单词或者符号(词法符号,token)的过程称为词法分析(lexicalanalysis)或者词法符号化(tokenizing)。我们把可以将输入文本转换为词法符号的程序称为词法分析器(lexer)。词法分析器可以将相关的词法符号归类,例如INT(整数)、ID(标识符)、FLOAT(浮点数)等。当语法分析器不关心单个符号,而仅关心符号的类型时,词法分析器就需要将词汇符号归类。词法符号包含至少两部分信息:词法符号的类型(从而能够通过类型来识别词法结构)和该词法符号对应的文本。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMUGlpbOHNGFoD4xwJ7lxmuVPVRGXctAoX0Rg9OYEfaEPBj3MZVibyFASBuYSs9gNQyAmKSYO4Pn1Q/640?wx_fmt=png&from=appmsg) + +Java 词法规则示例: + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMUGlpbOHNGFoD4xwJ7lxmuuLDjMLFnZfHULKvmCO4vvrtX9vEnCdpfg4rib8XQBicEFpyE1dH74wMg/640?wx_fmt=png&from=appmsg) + +接下来介绍一下词法规则是如何编写的。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNFp93h7jYGKGDcTibRGQpAlDN5xdXc5l19uEovB5EupSvFQeiaK62MvaTxMcRfZiaqacvibVoklY0AMA/640?wx_fmt=png&from=appmsg) + +如上图所示词法规则以大写的字母开头,或者以冒号开头后跟大写字母,这样做是为了与之后所要介绍的语法规则做区分。例如上图中我们就给出了一些示例的规则,定义了INT,ID,STRING类型的词法单元,冒号后面是对这些词法单元的描述。 + +这种词法规则的类型被称之为标准词法符号类型,这一类词法规则必须用大写字母开头,经过ANTLR 4工具处理会生成可直接在解析器中引用的符号,其规则匹配的优先级由在语法文件中声明词法规则的顺序和词法规则的长度来决定。 + +其中有很多符号,比如“+”代表着 INTEGER 这一词法规则使用出现至少一次的自然数组成的,而 IDENTIFIER 这一规则中的“*”则代表着 IDENTIFIER 这一词法规则是由大小写字母或下划线加上至少出现0次的单词字符组成的。而 STRING 词法规则中单引号中间的内容则代表着中间的内容直接匹配,是固定的。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNFp93h7jYGKGDcTibRGQpAlfQmE7UfzQdnygUevQichUk0pz1WicrtzBsWJicw594RlNKODV0KbLib7nw/640?wx_fmt=png&from=appmsg) + +第二类词法规则被称之为片段规则,通过关键字 **fragment** 来定义。 + +片段规则具有以下特点:首先片段规则是不能独立匹配的,fragment 规则不能直接用于匹配输入文本。它们只能被其他非片段的词法规则所引用。 + +将一条规则声明为 fragment 可以告诉 ANTLR,该规则本身不是一个词法符号,它只会被其他的词法规则使用。这意味着我们不能在文法规则中引用 HEX_DIGIT。 + +通常使用片段规则是为了提高可读性和重用性,通过将常用的字符模式提取为片段规则,可以使词法规则更加简洁和易于维护。例如,可以将字母或数字的模式定义为片段规则,然后在多个词法规则中引用它们。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNFp93h7jYGKGDcTibRGQpAlSXLNTPKsS0se2YZFjYSiaibLMeDwmt0bODXODQG3bEuSCDOhQibBSc3bA/640?wx_fmt=png&from=appmsg) + +第三类词法规则被称之为**指令规则**。 + +- 第一种被称之为跳过指令,ANTLR 4在词法分析过程中会忽略这些匹配的空白字符,不会将它们作为(token)传递给语法分析器; +- 第二种被称之为通道指令,使用 -> channel(HIDDEN) 指令,ANTLR 将这些注释标记发送到一个隐藏通道,使得它们不会被默认的语法分析器处理,但仍然可以在需要时访问; +- 第三种被称之为模式指令,使用 -> pushMode(XML_MODE) 指令,ANTLR 会切换到 XML_MODE 模式,这允许在不同的上下文中使用不同的词法规则集; +- 最后一种被称之为类型指令,使用 -> type(DOLLAR_SIGN) 指令,ANTLR 会将匹配的标记类型动态设置为 DOLLAR_SIGN,这可以用于在语法分析中对不同类型的标记进行区分和处理。 + +## 语法规则 + +语法文件的规则以小写字母开头。 + +首先我们来介绍语法规则的规则组成元素。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNFp93h7jYGKGDcTibRGQpAlDj1q0xDFrfib3ib2L0L96KXVoKYk1ib7oVTkAdSJ6JBaB5vBbYNTT3DtA/640?wx_fmt=png&from=appmsg) + +以上名为 assignment 的语法规则中所包含的大写字母序列 IDENTIFIER 被称之终结符,它来自词法分析器,我们在词法规则中会对其进行定义。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNFp93h7jYGKGDcTibRGQpAliaTB1XM3vgy6o36ics5fmgRBhThJ6dP5c8zXvdNV9pIlE8tZMLYNcRQA/640?wx_fmt=png&from=appmsg) + +与此相对的是非终结符,比如以上 expression 语法规则中的 term,这些非终结符,由小写字母命名,并且由其他规则所定义。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNFp93h7jYGKGDcTibRGQpAlnXGaWTEibwzujpW5YibicudHXd51Ujq7Or0sGU7TJUu2RCOF2y9ehQXEQ/640?wx_fmt=png&from=appmsg) + +除了之前介绍的终结符和非终结符两种元素之外,还有带参数的规则和带返回值的规则。因此,参数和返回值也是语法规则的重要元素。 + +[String className],表示这个规则接受一个参数 className,类型为 String。在解析过程中,可以将外部传入的类名用于匹配。[Object value],表示这个规则在匹配成功后会返回一个 Object 类型的值,存储在 value 中。 + +ANTLR 4的语法规则的核心语法构造分为四种模式,分别是序列模式、选择模式、分组模式、循环模式。 + +**序列模式** + +```java +sqlSelect : SELECT column FROM table WHERE condition; +``` + +元素必须严格按顺序出现(如 SQL 语句结构)。 + +**选择模式** + +```java +dataType : INT | STRING | BOOL; +``` + +多选一匹配(如数据类型只能为三者之一)。 + +**分组模式** + +```java +functionCall : ID '(' (arg (',' arg)*)? ')'; +``` + +括号强制组合子规则(如函数参数列表的逗号分隔结构)。 + +**循环模式** + +```java +emailList : address (',' address)+; +``` + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNFp93h7jYGKGDcTibRGQpAl7745lpeR6Kuw7fuK4EjNCgQO0hfNUu2fEC1AGQvusGcbicmzMwDseYQ/640?wx_fmt=png&from=appmsg) + +后缀运算符控制重复次数(如至少一个邮箱地址的逗号分隔列表)。 + +### 规则标签 + +在 ANTLR 4 中,规则标签(Rule Labels)是提升语法可读性、精确控制解析树生成的关键机制,我们可以使用 # 给最外层的备选分支添加标签,以获得更加精确的语法分析器监听器事件。一条规则中的备选分支要么全部带上标签,要么全部不带标签。标签主要有两种应用形式: + +------ + +**分支备选标签(Alternative Labels)** + +在规则的选择分支(`|`)中标注备选项: + +```java +expression + : left=expr '+' right=expr # AddExpr // # 定义标签 + | left=expr '*' right=expr # MulExpr + | NUMBER # NumLiteral + ; +``` + +**作用**: + +> 为每个分支生成独立的上下文类(如`AddExprContext`),在监听器/访问器中提供类型精确的访问方法 + +**生成代码优势**: + +``` +// 自动生成精确的进入/退出方法 +@Override +public void enterAddExpr(MyParser.AddExprContext ctx) { + // 直接访问带标签的元素 + ExprContext left = ctx.left; // 无需遍历子节点 + ExprContext right = ctx.right; +} +``` + +------ + +**元素标签(Element Labels)** + +在规则中标记特定子元素: + +``` +funcCall : func=ID '(' args+=expr (',' args+=expr)* ')'; +``` + +**三种标记方式**: + +| 标签语法 | 适用对象 | 返回值类型 | 访问示例 | +| :--------------: | :------: | :---------------: | :------------------------------: | +| `label=TOKEN` | 词法符号 | `TerminalNode` | `ctx.ID().getText()` | +| `label=rule` | 规则引用 | `RuleContext`子类 | `ctx.expr().value` | +| `labelList+=...` | 重复元素 | `List` | `for (exprContext e : ctx.args)` | + +**实战应用场景** + +- 场景1:四则运算精确解析 + +```java +expr + : left=expr op=('*'|'/') right=expr # MulDiv + | left=expr op=('+'|'-') right=expr # AddSub + | NUM # Number + | '(' expr ')' # Parens + ; +``` + +**生成的监听器接口**: + +```java +void enterMulDiv(ExprParser.MulDivContext ctx); +void enterAddSub(ExprParser.AddSubContext ctx); +void exitMulDiv(ExprParser.MulDivContext ctx); +// ... +``` + +- 场景2:函数调用语义分析 + +```java +functionCall + : func=ID '(' + (firstArg=expr (',' otherArgs+=expr)*)? + ')' # FuncCall + ; +``` + +**在访问器中直接获取元素**: + +```java +public Object visitFuncCall(FuncCallContext ctx) { + String funcName = ctx.func.getText(); + List args = new ArrayList<>(); + if(ctx.firstArg != null) { + args.add(ctx.firstArg); + args.addAll(ctx.otherArgs); + } + // ...处理函数调用 +} +``` + +# TokenStream + +词法分析器处理字符序列并将生成的词法符号提供给语法分析器,语法分析器随即根据这些信息来检查语法的正确性并建造出一棵语法分析树。这个过程对应的ANTLR 类是 CharStream、Lexer、Token、Parser,以及 ParseTree。连接词法分析器和语法分析器的“管道”就是 TokenStream。下图展示了这些类型的对象在内存中的交互方式。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScN4J4O1ibCDpu5gM5zAXibE5faUDyibyvydyIO119zMK7skMfBVUW6KAIonmMuzPMfO7wepPGfD4s1fw/640?wx_fmt=png&from=appmsg) + +ParseTree 的子类 RuleNode 和 TerminalNode ,二者分别是子树的根节点和叶子节点。RuleNode 有一些令人熟悉的方法,例如 getChild() 和 getParent() ,但是,对于一个特定的语法,RuleNode 并不是确定不变的。为了更好地支持对特定节点的元素的访问,ANTLR 会为每条规则生成一个 RuleNode 的子类。如下图所示,在我们的赋值语句的例子中,子树根节点的类型实际上是:StatContext、AssignContext 以及 ExprContext。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScN4J4O1ibCDpu5gM5zAXibE5fJPwSLxSkfJy8wg2g6dNBFQTCAYkroBicHV7PVGc5UR1eWo2TicibTTO5g/640?wx_fmt=png&from=appmsg) + +因为这些根节点包含了使用规则识别词组过程中的全部信息,它们被称为上下文(context)对象。每个上下文对象都知道自己识别出的词组中,开始和结束位置处的词法符号,同时提供访问该词组全部元素的途径。例如,AssignContext 类提供了方法 ID() 和方法 expr() 来访问标识符节点和代表表达式的子树。 + +# 监听器和访问器 + +ANTLR 的运行库提供了两种遍历树的机制。默认情况下,ANTLR 使用内建的遍历器访问生成的语法分析树,并为每个遍历时可能触发的事件生成一个语法分析树监听器接口(parse-tree listener interface)。监听器非常类似于 XML 解析器生成的 SAX 文档对象。SAX 监听器接收类似 startDocument() 和 endDocument() 的事件通知。一个监听器的方法实际上就是回调函数,正如我们在图形界面程序中响应复选框点击事件一样。除了监听器的方式,我们还将介绍另外一种遍历语法分析树的方式:访问者模式(vistor pattern)。 + +## 监听器 + +为了将遍历树时触发的事件转化为监听器的调用,ANTLR 运行库提供了 ParseTreeWalker 类。我们可以自行实现 ParseTreeListener 接口,在其中填充自己的逻辑代码(通常是调用程序的其他部分),从而构建出我们自己的语言类应用程序。ANTLR 为每个语法文件生成一个 ParseTreeListener 的子类,在该类中,语法中的每条规则都有对应的 enter 方法和 exit 方法。例如,当遍历器访问到 assign 规则对应的节点时,它就会调用 enterAssign() 方法,然后将对应的语法分析树节点——AssignContext 的实例——当作参数传递给它。在遍历器访问了 assign 节点的全部子节点之后,它会调用 exitAssign() 。下图用粗虚线标识了 ParseTreeWalker对语法分析树进行深度优先遍历的过程。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScN4J4O1ibCDpu5gM5zAXibE5fGlEhwmC07TibicibWVXgBAGZ5bMyC2WtRc3ArQI9ZmlHEgwPquCYxibmicA/640?wx_fmt=png&from=appmsg) + +下图显示了在我们的赋值语句生成的语法分析树中,ParseTreeWalker 对监听器方法的完整的调用顺序。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScN4J4O1ibCDpu5gM5zAXibE5fMKFZmIOFCI0fuVickY6icEyCmKfEPWmkQIKXViaETAz1rviaLgD6srKIDg/640?wx_fmt=png&from=appmsg) + +监听器机制的优秀之处在于,这一切都是自动进行的。我们不需要编写对语法分析树的遍历代码,也不需要让我们的监听器显式地访问子节点。 + +## 访问器 + +有时候,我们希望控制遍历语法分析树的过程,通过显式的方法调用来访问子节点。下图是是使用常见的访问者模式对我们的语法分析树进行操作的过程。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScN4J4O1ibCDpu5gM5zAXibE5fnFo6m8UQCFDULbPds1MCVTWVXCaWPaticRibxFWOEq5cC1ykHgUjUAcA/640?wx_fmt=png&from=appmsg) + +其中,粗虚线显示了对语法分析树进行深度优先遍历的过程。细虚线标示出访问器方法的调用顺序。我们可以在自己的程序代码中实现这个访问器接口,然后调用visit() 方法来开始对语法分析树的一次遍历。 + +```java +ParseTree tree=...; // tree是语法分析得到的结果 +MyVisitor v = new MyVisitor(); +v.visit(tree); +``` + +ANTLR 内部为访问者模式提供的支持代码会在根节点处调用 visitStat() 方法。接下来,visitStat() 方法的实现将会调用 visit() 方法,并将所有子节点当作参数传递给它,从而继续遍历的过程。或者,visitMethod() 方法可以显式调用 visitAssign() 方法等。ANTLR会提供访问器接口和一个默认实现类,免去我们一切都要自行实现的麻烦。这样,我们就可以专注于那些我们感兴趣的方法,而无须覆盖接口中的方法。 + +同时访问者机制支持泛型返回值,可以实现数据聚合。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNFp93h7jYGKGDcTibRGQpAl0lxNWsgnPSKVMnnHHdmVjngyv2ozxmBaFNXGGdmIxdjPt9jtNZG5zw/640?wx_fmt=png&from=appmsg) + +**访问器机制和监听器机制的最大的区别在于,监听器的方法会被 ANTLR 提供的遍历器对象自动调用,而在访问器的方法中,必须显式调用 visit 方法来访问子节点。忘记调用visit() 的后果就是对应的子树将不会被访问。** + +# 语义判定 + +语义判定(Semantic Predicates)允许在语法规则中嵌入布尔表达式,从而在运行时动态控制解析过程。这使得 ANTLR4 能够处理上下文相关的语法结构。 + +基本语法: + +``` +ruleName + : {布尔表达式}? 规则元素 // 验证型判定 + | {布尔表达式}?=> 规则元素 // 门控型判定 + ; +``` + +## 判定类型 + +**验证型判定** + +- 语法:`{布尔表达式}?` +- 行为: + - 尝试匹配规则元素 + - 如果匹配成功,评估布尔表达式 + - 如果表达式为 `false`,放弃当前分支并尝试其他备选分支 + +``` +expr + : {isType("int")}? ID // 只有当 isType("int") 为 true 时才匹配 + | INT + ; +``` + +**门控型判定** + +- 语法:`{布尔表达式}?=>` +- 行为: + - 在尝试匹配规则元素前评估布尔表达式 + - 如果表达式为 `false`,立即放弃整个分支 + - 不会尝试匹配规则元素 + +``` +statement + : {inLoop()}?=> 'break' ';' // 只有在循环中才允许 break + | 'continue' ';' + ; +``` + +## 实现机制 + +**在语法文件中声明**: + +```java +grammar ContextSensitive; + +@parser::members { + private SymbolTable symbolTable = new SymbolTable(); + + private boolean isType(String id) { + return symbolTable.isType(id); + } +} + +expr + : {isType($ID.text)}? ID // 使用语义判定 + | INT + ; +``` + +ANTLR 会将语义判定转换为解析器代码: + +```java +public class ContextSensitiveParser extends Parser { + // ... + + public final ExprContext expr() { + // 尝试第一个备选分支 + if (isType(input.LT(1).getText())) { + // 创建上下文对象 + // 匹配 ID + } + // 否则尝试第二个分支 + else { + // 匹配 INT + } + } +} +``` + +# Channel + +在 ANTLR 4 中,通道(channels)是一种强大的机制,用于将词法标记(tokens)分类处理。ANTLR 4 有两个预定义通道: + +- 默认通道 (Token.DEFAULT_CHANNEL),通道号: 0,包含所有需要被解析器处理的标记。 +- 隐藏通道 (Token.HIDDEN_CHANNEL),通道号: 1,包含所有不需要被解析器直接处理的标记。 + +**通道与 skip 的区别** + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScN4J4O1ibCDpu5gM5zAXibE5f2ibRwteApMbic0uAS1sZGmgE4bq3zR6Vw033Rorib0tArFPaIbYzeg3dw/640?wx_fmt=png&from=appmsg) + +**自定义通道** + +``` +// ===== 1. 声明通道 ===== +channels { + ERROR_CHANNEL, // 自定义错误信息通道 + HIDDEN_COMMENTS // 隐藏注释通道 +} + +// ===== 2. 将词法规则定向到通道 ===== +ERROR_TOKEN : '' -> channel(ERROR_CHANNEL); // 捕获错误标记 +LINE_COMMENT : '//' ~[\r\n]* -> channel(HIDDEN_COMMENTS); // 隐藏注释 +BLOCK_COMMENT : '/*' .*? '*/' -> channel(HIDDEN_COMMENTS); + +// ===== 3. 保留传统空白符处理 ===== +WS : [ \t\r\n]+ -> skip; // 完全跳过空白符 +``` + +ANTLR 4 通过 `channels{}` 声明自定义通道,并用 `-> channel(NAME) `将词法规则输出定向到指定通道,保留但隔离特殊内容。 + +# 嵌入动作 + +ANTLR 的嵌入动作(Embedded Actions)是在语法规则中**直接插入目标语言代码**的机制,它允许开发者在解析过程的关键节点执行自定义逻辑。 + +``` +语法规则 { 代码块 } +``` + +ANTLR 在解析时会在对应位置**实时执行这些代码** + +**执行时机** + +1. **元素匹配前**:`{代码} 规则元素` +2. **元素匹配后**:`规则元素 {代码}` +3. **规则匹配完成**:`规则元素 @after {代码}` + +------ + +**动作类型与代码示例** + +- **简单打印动作**(调试追踪) + +``` +expression + : left=expression '+' { System.out.println("检测到加号"); } + right=expression + { System.out.println("完成加法: "+$left.value+"+"+$right.value); } + ; +``` + +**输出示例**: + +``` +检测到加号 +完成加法: 5+3 +``` + +- **条件拦截动作**(语义检查) + +``` +vectorOperation + : ID '=' (vec1=vector '×' vec2=vector + { + if($vec1.dimension != $vec2.dimension) + throw new RuntimeException("维度不匹配"); + }) + { System.out.println("叉积运算完成"); } + ; +``` + +- **动态计算动作**(属性传递) + +``` +number returns [int value] + : digits=INT { $value = Integer.parseInt($digits.text); } + | hex='0x' hexDigits=HEX + { $value = Integer.parseInt($hexDigits.text,16); } + ; +``` + +- **集合构造动作**(数据聚合) + +``` +jsonArray returns [List list = new ArrayList<>()] + : '[' + (first=jsonValue { $list.add(first); } + (',' next=jsonValue { $list.add(next); })* + )? ']' + ; +``` + +- **符号表管理动作**(语义分析) + +``` +variableDecl + : type ID + { + Symbol sym = new Symbol($ID.text, $type.text); + currentScope.addSymbol(sym); + } + '=' expr ';' + ; +``` + +- **自动代码生成**(DSL编译) + +``` +sqlSelect + : 'SELECT' columns+=column (',' columns+=column)* + { out.write("SELECT " + $columns.get(0).text); + for(int i=1; i<$columns.size(); i++) { + out.write("," + $columns.get(i).text); + } + } + 'FROM' table=ID + { out.write(" FROM " + $table.text); } + ; +``` + +注意:动作会使语法与目标语言耦合,优先使用监听器/访问器模式,避免过度使用。 + +# 处理优先级、左递归和结合性 + +在自顶向下的语法和手工编写的递归下降语法分析器中,处理表达式都是一件相当棘手的事情,这首先是因为大多数语法都存在歧义,其次是因为大多数语言的规范使用了一种特殊的递归方式,称为左递归(left recursion)。 + +自顶向下的语法和语法分析器的经典形式无法处理左递归。为了阐明这个问题,假设有一种简单的算术表达式语言,它包含乘法和加法运算符,以及整数因子。表达式是自相似的,所以,很自然地,我们说,一个乘法表达式是由*连接的两个子表达式,一个加法表达式是由+连接的两个子表达式。另外单个整数也可以作为简单的表达式。这样写出的就是下列看上去非常合理的规则: + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMUGlpbOHNGFoD4xwJ7lxmu7eltp0aeSUhEKwJRpuwKZ85ppFQZVnExu8fEiak6k3Leicw25YDOZbiaQ/640?wx_fmt=png&from=appmsg) + +问题在于,对于某些输入文本而言,上面的规则存在歧义。换句话说,这条规则可以用不止一种方式匹配某种输入的字符流,这个语法在简单的整数表达式和单运算符表达式上工作得很好——例如1+2和1*2——是因为只存在一种方式去匹配它们。对于1+2,上述语法只能用第二个备选分支去匹配,如下图左侧的语法分析树所示。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMUGlpbOHNGFoD4xwJ7lxmuAvL2y4L0RTiblKJTfR5smoKbdn3VequOa1fjAr3ljSiaOaxaeCyU8Fqw/640?wx_fmt=png&from=appmsg) + +但是对于 1+2*3 这样的输入而言,上述规则能够用两种方式解释它,如上图中间和右侧的语法分析树所示。它们的差异在于,中间的语法分析树表示将1加到2和3相乘的结果上去,而右侧的语法分析树表示将1和2相加的结果与3相乘。这就是运算符优先级带来的问题,传统的语法无法指定优先级。大多数语法工具,例如Bison,使用额外的标记来指定运算符优先级。 + +与之不同的是,**ANTLR 通过优先选择位置靠前的备选分支来解决歧义问题**,这隐式地允许我们指定运算符优先级。例如,expr 规则中,乘法规则在加法规则之前,所以ANTLR在解决歧义问题时会优先处理乘法。默认情况下,ANTLR按照我们通常对*和+的理解,将运算符从左向右地进行结合。尽管如此,一些运算符——例指数运算符——是从右向左结合的,所以我们需要在这样的运算符上使用 assoc 选项手工指定结合性。这样,输入的 2^3^4 就能够被正确解释为2^(3^4): + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMUGlpbOHNGFoD4xwJ7lxmuGdu9n1mtfwrf6oic05n3xIesZ33dl0y4tIDl1OKaMYRZvtPDvh1ppBg/640?wx_fmt=png&from=appmsg) + +注:在ANTLR 4.2之后, 需要被放到备选分支的最左侧,否则会收到警告。在本例中,正确写法是: + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMUGlpbOHNGFoD4xwJ7lxmu32RZeknL2e40xq5gADxJng1mtAylovTiaCTN3QcbbMCt1UqXAabiaHuw/640?wx_fmt=png&from=appmsg) + +如下图所示的语法分析树展示了^符号的左结合版本和右结合版本在处理相同输入时的差异。通常人们采用右侧语法分析树所代表的解释方式,不过,语言设计者可以自由地决定使用哪一种结合性。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMUGlpbOHNGFoD4xwJ7lxmu1DpDicym2eEg1u6iaDrDHaOVSMYVM2w2tJgdv4c0xzmbqyGK3RdAgNEA/640?wx_fmt=png&from=appmsg) + +若要将上述三种运算符组合成为同一条规则,我们就必须把^放在最前面,因为它的优先级比*和+都要高(1+2^3的结果是9)。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMUGlpbOHNGFoD4xwJ7lxmuOmqYRmFntAw86ibOJFTmPCBsjm882HREjiakEwjLxHtI4qFnDGicmWK1w/640?wx_fmt=png&from=appmsg) + +ANTLR 4的一项重大改进就是,它已经可以处理直接左递归了。左递归规则是这样的一种规则:在某个备选分支的最左侧以直接或者间接方式调用了自身。上面的例子中的expr规则是直接左递归的,因为除INT之外的所有备选分支都以expr规则本身开头(它同时也是右递归(rightrecursive)的,因为它的某些备选分支在最右侧引用了expr)。虽然ANTLR 4已经能够处理直接左递归,但是它还无法处理间接左递归。这意味着我们无法将expr规则分解为下列规则,尽管它们在语义上等价: + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMUGlpbOHNGFoD4xwJ7lxmuwF6yYa3xcPDnU9EeTPYuJpCGTFYpEBD88vAPibFdI2C8AibEnibZ7eziaw/640?wx_fmt=png&from=appmsg) + +# 非贪婪匹配 + +在 ANTLR 中,**非贪婪匹配(Non-Greedy Matching)** 是处理文本模式的特殊策略,它会尽可能少地匹配字符(即采用"最小匹配"原则)。这与默认的贪婪匹配(尽可能多匹配)形成对比,是解决词法歧义的关键技术。 + +**贪婪匹配(默认行为)** + +``` +STRING : '"' .* '"'; // 匹配从第一个"到最后一个" +``` + +**非贪婪匹配** + +``` +STRING_LAZY : '"' .*? '"'; // ? 启用非贪婪 +``` + +**通配符模式说明**: + +| 模式 | 符号 | 匹配策略 | +| :----: | :---: | :----------: | +| 贪婪 | `.*` | 最长可能匹配 | +| 非贪婪 | `.*?` | 最短可能匹配 | + +**实战应用场景** + +- 场景1:注释匹配 + +``` +// 错误:贪婪匹配会吃光所有内容 +DOC_COMMENT : '/*' .* '*/'; + +// 正确:非贪婪只匹配最近的一对 +DOC_COMMENT_LAZY : '/*' .*? '*/'; +``` + +- 场景2:模板字符串 + +``` +TEMPLATE : '`' ('\\`' | .)*? '`'; +``` + +正确处理带转义符的模板: + +- 场景3:XML标签内联 + +``` +TAG_CONTENT : '<' .*? '>'; +``` + +# 辅助类 + +## ParseTreeProperty + +`ParseTreeProperty` 是 ANTLR 4 中一个强大的辅助类,用于将自定义数据与解析树(Parse Tree)中的节点关联起来。它是实现属性文法(Attribute Grammar)的核心工具,特别适用于需要在语法分析过程中计算和传递属性的场景。 + +`ParseTreeProperty` 主要用于解决以下问题: + +1. **存储节点相关数据**:为每个解析树节点关联自定义属性 +2. **实现属性传递**:在树遍历过程中收集和传递上下文信息 +3. **实现代码生成**:保存每个节点的代码生成结果 +4. **类型检查**:记录表达式的类型信息 +5. **符号表关联**:将作用域和符号表与语法结构关联 + +```java +/ 1. 创建数据容器 +ParseTreeProperty dataMap = new ParseTreeProperty<>(); + +// 2. 向节点注入数据(通常在监听器/访问器中) +@Override +public void exitAddExpr(CalcParser.AddExprContext ctx) { + int left = dataMap.get(ctx.left); // 取左子树数据 + int right = dataMap.get(ctx.right); + int result = left + right; + dataMap.put(ctx, result); // 当前节点存储计算结果 +} + +// 3. 从根节点获取最终结果 +public int getResult(ParseTree tree) { + return dataMap.get(tree); // 返回根节点存储的计算结果 +} +``` + +## TokenStreamRewriter + +`TokenStreamRewriter` 是 ANTLR4 中一个强大的工具类,用于在不修改原始令牌流的情况下,对令牌流进行非破坏性编辑。它特别适用于源代码转换、重构和代码生成等场景。 + +其中的关键之处在于,TokenStreamRewriter 对象实际上修改的是词法符号流的“视图”而非词法符号流本身。它认为所有对修改方法的调用都只是一个“指令”,然后将这些修改放入一个队列;在未来词法符号流被重新渲染为文本时,这些修改才会被执行。在每次我们调用 getText() 的时候,rewriter 对象都会执行上述队列中的指令。 + +**简单使用示例**:在方法调用前插入日志 + +```java +public class RewriterExample { + public static void main(String[] args) { + // 1. 创建输入流 + String input = "public class Test {\n" + + " public void method() {\n" + + " System.out.println(\"Hello\");\n" + + " }\n" + + "}"; + CharStream charStream = CharStreams.fromString(input); + + // 2. 创建词法分析器和令牌流 + JavaLexer lexer = new JavaLexer(charStream); + CommonTokenStream tokens = new CommonTokenStream(lexer); + + // 3. 创建重写器 + TokenStreamRewriter rewriter = new TokenStreamRewriter(tokens); + + // 4. 创建解析器 + JavaParser parser = new JavaParser(tokens); + ParseTree tree = parser.compilationUnit(); + + // 5. 遍历解析树并修改 + ParseTreeWalker walker = new ParseTreeWalker(); + walker.walk(new InsertLogListener(rewriter), tree); + + // 6. 获取修改后的文本 + System.out.println(rewriter.getText()); + } + + static class InsertLogListener extends JavaBaseListener { + private final TokenStreamRewriter rewriter; + + public InsertLogListener(TokenStreamRewriter rewriter) { + this.rewriter = rewriter; + } + + @Override + public void enterMethodCall(JavaParser.MethodCallContext ctx) { + // 获取方法名令牌 + Token methodNameToken = ctx.Identifier().getSymbol(); + + // 在方法调用前插入日志语句 + String logStmt = "\n System.out.println(\"Calling method: " + + methodNameToken.getText() + "\");"; + + rewriter.insertBefore(methodNameToken.getTokenIndex(), logStmt); + } + } +} +``` + +**输出结果**: + +```java +public class Test { + public void method() { + System.out.println("Calling method: println"); + System.out.println("Hello"); + } +} +``` + +# 错误报告与恢复 + +ANTLR 的错误报告与恢复机制是其生成健壮解析器的核心,它通过智能的错误检测、精确报告及自动恢复策略,确保即使面对非法输入也能进行结构化处理而非直接崩溃。 + +对于词法错误和语法错误,ANTLR 4 会定位错误的起始位置,向后删除字符直到发现合法的 token 边界,然后就会接着解析后续输入。 + +``` +// 自动生成详细的错误诊断 +line 5:8 missing '}' at '{' +line 10:22 mismatched input ';' expecting ',' +``` + +- **信息结构**: + + ``` + 位置: 行号:列号 + 类型: [missing|mismatched|extraneous] + 详情: 期望内容/实际内容 + ``` + +**自定义错误处理器** + +重写 `BaseErrorListener`: + +```java +public class VerboseListener extends BaseErrorListener { + @Override + public void syntaxError(Recognizer recognizer, + Object offendingSymbol, + int line, int charPos, + String msg, RecognitionException e) { + // 生成更友好的错误提示 + String error = String.format("[CUSTOM] Line %d:%d - %s", line, charPos, msg); + System.err.println(error); + } +} +// 注册自定义监听器 +parser.removeErrorListeners(); +parser.addErrorListener(new VerboseListener()); +``` + +# 性能优化 + +## 提高语法分析器的速度 + +ANTLR 4 的自适应语法分析策略功能比 ANTLR 3 更加强大,不过这是以少量的性能损失为代价的。如果你需要尽可能快的速度和尽可能少的内存占用,你可以使用两步语法分析策略。第一步使用功能稍弱的语法分析策略——SLL——在大多数情况下它已经足够了(它和ANTLR 3的策略相似,只是不需要回溯)。如果第一步的语法分析失败,那么就必须使用全功能的 LL 语法分析。这是因为,在第一步失败后,我们无法知道原因究竟是真正的语法错误,还是 SLL 的功能不够强大 + +由于能够通过 SLL 的输入一定能够通过全功能的 LL,所以一旦第一步成功,就无须使用更昂贵的策略。 + +```java + try { + parser.compilationUnit(); + //如果抵达此处,证明没有语法错误,SLL(*)就够了 + //无需使用全功能的LL(*) + } catch (RuntimeException ex) { + if (ex.getClass() == RuntimeException.class && + ex.getCause() instanceof RecognitionException) { + //BailErrorStrategy会将RecognitionExceptions封装在 + // RuntimeException中,所以这里需要检查是不是 + //一个真正的RecognitionException + tokenStream.reset();//回滚输入流 + //重新使用标准的错误监听器和错误处理器 + parser.addErrorListener(ConsoleErrorListener.INSTANCE); + parser.setErrorHandler(new DefaultErrorStrategy()); + parser.getInterpreter().setPredictionMode(PredictionMode.SLL); + parser.compilationUnit(); + + + parser.addErrorListener(new SyntaxErrorListener()); + ParseTree tree = parser.compilationUnit(); + // 使用访问器转换DSL + Map externalVarMaps = new HashMap<>(); + externalVarMaps.put("features", Sets.newHashSet("test_tz_string_auto_test", "test_feature_999", "sys_attr5")); + ParentVisitor visitor = new ParentVisitor(123L, tokenStream, parser, externalVarMaps); + String dsl = visitor.visit(tree); + log.info("Generated DSL:\n{}", dsl); + } + + } +``` + +如果第二步失败,那就意味着一个真正的语法错误。 + +## 无缓冲的字符流和词法符号流 + +因为 ANTLR 的识别器在默认情况下会将输入的完整字符流和全部词法符号放入缓冲区,所以它无法处理大小超过内存的文件,也无法处理类似套接字(socket)连接之类的无限输入流。为解决此问题,你可以使用字符流和词法符号流的无缓冲版本:UnbufferedCharStream 和 UnbufferedTokenStream,它们使用一个滑动窗口来处理流。 + +为展示二者的实际应用,下图是一个 CSV语法,它计算一个文件中两列浮点数的和: + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNEy6jVOX0gaqFrNhAicGdJSyOPQx6FVvyIDicZNZFvibVCQN8VYwlc8T2ZKEXsMKjQ46SDtrzKf5aIw/640?wx_fmt=png&from=appmsg) + + + +如果你需要的只是每一列的和,你就应该在内存中只保留一个或两个词法符号用于记录结果。欲关闭 ANTLR 的缓冲功能,需要完成三件事情。首先,使用无缓冲的流代替常见的 ANTLFileStream 和 CommonTokenStream。其次,传给词法分析器一个词法符号工厂,将输入流中的字符拷贝到生成的词法符号中去。否则,词法符号的 getTex() 方法就会尝试访问可能已经不再可用的字符流。最后,阻止语法分析器建立语法分析树。如下图标记的关键代码: + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNEy6jVOX0gaqFrNhAicGdJSfQ6U5EwyDaRTMLwBlvibzFfIEmicYpHmMG2qMyfSSsqiaYlOQZ6HaztCA/640?wx_fmt=png&from=appmsg) + +当效率是首要目标时,无缓冲流是非常有用的。使用它们的缺点是你需要手工处理与缓冲区相关的事情。例如,你不能在规则的内嵌动作中使用 $text,因为它们是从输入流中获取文本的。 + +# 结尾 + +这篇关于 ANTLR 的技术指南到此告一段落。作为领域特定语言(DSL)构建的利器,ANTLR 通过其强大的语法解析能力、灵活的监听器/访问器机制,以及高效的错误恢复策略,彻底革新了语言处理技术的开发范式。 + +无论是设计数据库查询语言、配置文件解析器,还是实现复杂的领域专用逻辑,ANTLR 都提供了从词法分析到语法树遍历的全套解决方案。其自动生成的解析器代码和直观的规则定义方式,让开发者能专注于业务逻辑而非底层细节,真正实现了"用语法驱动开发"的高效实践。通过掌握 ANTLR,你已拥有了一把打开自定义语言世界的钥匙。 From 53103a55f7ac7f3d6835e4aafd06e0e33b78c209 Mon Sep 17 00:00:00 2001 From: BookSea <65435519+BookaiCode@users.noreply.github.com> Date: Mon, 4 Aug 2025 13:57:43 +0800 Subject: [PATCH 05/12] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c09df6c..568fa9d 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,7 @@ - [Scala语言入门:初学者的基础语法指南](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247487245&idx=1&sn=d089e22890f1f7449b7cf34e3cf2f6ed&chksm=cf847cc8f8f3f5deb39556f4229bafb6f1498906dc1d75040f90817bf0396117a7c2cdb498f9#rd) - [Groovy 初学者指南](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247487066&idx=1&sn=da9e3a9aff377d383e34e537e2f55666&chksm=cf847d9ff8f3f489011f26a784302ee68b9c1d7d57d52bc2c924a7c9b1a5f528ef2a417114c0#rd) -- [自研 DSL 神器:万字拆解 ANTLR 4 核心原理与高级应用]() +- [自研 DSL 神器:万字拆解 ANTLR 4 核心原理与高级应用](https://mp.weixin.qq.com/s/nFiEqhi1B_SxrZGCAqLgLw) ### :satellite: 设计模式 ### From 4318c0f3aea6700d5a3f3b9c8927d416a35d869f Mon Sep 17 00:00:00 2001 From: zhengshuhai <1242909896@qq.com> Date: Sun, 23 Nov 2025 16:45:30 +0800 Subject: [PATCH 06/12] PowerMock --- README.md | 1 + ...25\345\205\203\346\265\213\350\257\225.md" | 451 ++++++++++++++++++ 2 files changed, 452 insertions(+) create mode 100644 "docs/md/\345\205\266\344\273\226/\347\224\250\345\245\275PowerMock\357\274\214\350\275\273\346\235\276\346\220\236\345\256\232\351\202\243\344\272\233\350\256\251\344\275\240\345\244\264\347\226\274\347\232\204\345\215\225\345\205\203\346\265\213\350\257\225.md" diff --git a/README.md b/README.md index 568fa9d..8994a87 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,7 @@ - [实战Arthas:常见命令与最佳实践](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247488559&idx=1&sn=4b5003cb33446ab4a6173285fe9d83d3&chksm=cf8467eaf8f3eefc033de8f63cba9f0d7b2b5eb0ccfb5209f458a9ab447367b34954f296638b#rd) - [Maven实战](https://mp.weixin.qq.com/s/ErtWrRNzjJcR2ettUhAxsQ) - [不用Mockito写单元测试?你可能在浪费一半时间](https://mp.weixin.qq.com/s/NICubD9Yq0pn6qwpVIznfg) +- [用好PowerMock,轻松搞定那些让你头疼的单元测试]() ### :bulb: 资源 ### diff --git "a/docs/md/\345\205\266\344\273\226/\347\224\250\345\245\275PowerMock\357\274\214\350\275\273\346\235\276\346\220\236\345\256\232\351\202\243\344\272\233\350\256\251\344\275\240\345\244\264\347\226\274\347\232\204\345\215\225\345\205\203\346\265\213\350\257\225.md" "b/docs/md/\345\205\266\344\273\226/\347\224\250\345\245\275PowerMock\357\274\214\350\275\273\346\235\276\346\220\236\345\256\232\351\202\243\344\272\233\350\256\251\344\275\240\345\244\264\347\226\274\347\232\204\345\215\225\345\205\203\346\265\213\350\257\225.md" new file mode 100644 index 0000000..e405c0d --- /dev/null +++ "b/docs/md/\345\205\266\344\273\226/\347\224\250\345\245\275PowerMock\357\274\214\350\275\273\346\235\276\346\220\236\345\256\232\351\202\243\344\272\233\350\256\251\344\275\240\345\244\264\347\226\274\347\232\204\345\215\225\345\205\203\346\265\213\350\257\225.md" @@ -0,0 +1,451 @@ +> 面对无法Mock的静态方法、私有方法和final类,PowerMock为你打开一扇新的大门 + +作为一名Java开发者,单元测试是我们保证代码质量的重要环节。但在实际工作中,我们经常会遇到一些难以测试的代码场景:静态工具类、final类、私有方法等。传统的Mockito框架对这些情况束手无策,而PowerMock的出现正好解决了这些痛点。 + +# PowerMock是什么?为什么需要它? + +## PowerMock的核心定位 + +PowerMock是一个强大的Java单元测试框架,它通过扩展现有的Mock框架(如Mockito和EasyMock),提供了更强大的Mock能力。**PowerMock的核心价值在于它能够Mock那些传统Mock工具无法处理的情况**,包括静态方法、final类和方法、私有方法、构造函数等。 + +与普通Mock框架不同,PowerMock使用自定义的类加载器和字节码操作技术(基于Javassist和ASM库),在运行时修改类的行为,从而实现对这些"难以Mock"的场景的完全控制。 + +## PowerMock与Mockito的关系和区别 + +虽然PowerMock和Mockito都是用于单元测试的Mock框架,但它们在功能和定位上有着明显的区别: + +**Mockito**是一个轻量级、简单易用的Mock框架,适用于大多数日常测试场景。但它有明显的局限性:无法Mock静态方法、final类、私有方法和构造函数等。 + +**PowerMock**则是对Mockito的增强,填补了Mockito的功能空白。它不是替代Mockito,而是与Mockito协同工作,共同构建完整的单元测试解决方案。 + +两者核心区别体现在底层实现上:Mockito使用动态代理(CGLIB)技术,而PowerMock通过修改字节码来实现更强大的Mock能力。 + +正因为这种根本差异,PowerMock可以解决Mockito无法解决的问题。 + +## PowerMock解决的痛点 + +在日常开发中,我们经常会遇到以下测试难题: + +- **静态工具类**:如各种Util类中的静态方法。 +- **final类和final方法**:特别是第三方库中的final类。 +- **私有方法**:需要直接测试的私有方法逻辑。 +- **构造函数依赖**:方法内部通过new创建的对象。 +- **静态代码块和系统类**:如System.currentTimeMillis()。 + +这些问题使用传统Mock框架难以解决,而PowerMock为此提供了完整的解决方案 + +# 环境配置与基本用法 + +## 添加Maven依赖 + +要开始使用PowerMock,首先需要在项目中添加相关依赖。由于PowerMock需要与Mockito协同工作,需要同时添加两个依赖: + +```xml + + + org.powermock + powermock-module-junit4 + 2.0.9 + test + + + org.powermock + powermock-api-mockito2 + 2.0.9 + test + +``` + +**版本兼容性注意**:确保PowerMock与Mockito/JUnit版本匹配,具体兼容性关系可参考官方文档。 + +## 基本配置注解 + +使用PowerMock需要在测试类上添加必要的注解: + +```java +@RunWith(PowerMockRunner.class) // 必须使用PowerMockRunner +@PrepareForTest({StaticUtils.class, User.class}) // 声明需增强的类 +@PowerMockIgnore("javax.management.*") // 解决类加载器冲突 +public class UserServiceTest { + // 测试内容 +} +``` + +- `@RunWith(PowerMockRunner.class)`:告诉JUnit使用PowerMock的测试运行器。 +- `@PrepareForTest`:指定需要被PowerMock修改的类(包含静态方法、final方法等的类)。 +- `@PowerMockIgnore`:解决使用PowerMock后可能出现的类加载器冲突问题。 + +# PowerMock核心使用场景详解 + +## 静态方法Mock + +静态方法是最常见的测试难点之一,让我们看看PowerMock如何解决这个问题。 + +**场景示例**:假设我们有一个静态工具类,用于生成唯一ID: + +```java +public class IdGenerator { + public static String generateUniqueId() { + // 实际业务中可能包含复杂的逻辑或外部依赖 + return UUID.randomUUID().toString(); + } +} + +public class OrderService { + public String createOrder() { + String orderId = IdGenerator.generateUniqueId(); + // 创建订单的逻辑 + return "ORDER_" + orderId; + } +} +``` + +**测试代码**: + +```java +@RunWith(PowerMockRunner.class) +@PrepareForTest({IdGenerator.class, OrderService.class}) +public class OrderServiceTest { + + @Test + public void testCreateOrderWithStaticMock() { + // 1. 准备静态类的Mock + PowerMockito.mockStatic(IdGenerator.class); + + // 2. 预设静态方法行为 + PowerMockito.when(IdGenerator.generateUniqueId()).thenReturn("123e4567"); + + // 3. 创建被测试对象并调用被测方法 + OrderService orderService = new OrderService(); + String result = orderService.createOrder(); + + // 4. 验证结果 + assertEquals("ORDER_123e4567", result); + + // 5. 验证静态方法调用(必须调用) + PowerMockito.verifyStatic(IdGenerator.class); + IdGenerator.generateUniqueId(); + } +} +``` + +**关键点说明**: + +- `mockStatic()`方法用于告诉PowerMock要Mock哪个类的静态方法 +- 静态方法的Stubbing(定义行为)与普通Mockito语法类似 +- **必须调用**`verifyStatic()`来验证静态方法的调用,且需要在验证前调用一次 + +**常见坑点**:忘记调用`verifyStatic()`会导致无法验证静态方法是否被正确调用。 + +## 私有方法Mock + +测试私有方法一直存在争议,但在某些场景下(如复杂算法验证)确实有必要直接测试私有方法。 + +**场景示例**:一个包含复杂校验逻辑的UserService: + +```java +public class UserService { + public boolean validateUser(String username, String password) { + if (!isValidFormat(username) || !isValidFormat(password)) { + return false; + } + return internalComplexValidation(username, password); + } + + private boolean isValidFormat(String input) { + // 复杂的格式校验逻辑 + return input != null && input.length() >= 5; + } + + private boolean internalComplexValidation(String username, String password) { + // 非常复杂的内部校验逻辑 + // 可能涉及加密、数据库查询等 + return true; // 简化示例 + } +} +``` + +**测试代码**: + +```java +@RunWith(PowerMockRunner.class) +@PrepareForTest(UserService.class) +public class UserServiceTest { + + @Test + public void testPrivateMethod() throws Exception { + // 1. 创建被测类的Spy对象(部分真实调用) + UserService userService = new UserService(); + UserService spyService = PowerMockito.spy(userService); + + // 2. Stubbing:预设私有方法行为 + PowerMockito.doReturn(true).when(spyService, "isValidFormat", Mockito.anyString()); + + // 3. 调用被测方法 + boolean result = spyService.validateUser("testuser", "testpass"); + + // 4. 验证结果 + assertTrue(result); + + // 5. 验证私有方法被调用(可选) + PowerMockito.verifyPrivate(spyService,Mockito.times(2)) + .invoke("isValidFormat", Mockito.anyString()); + } + + @Test + public void testPrivateMethodWithArguments() throws Exception { + UserService userService = new UserService(); + UserService spyService = PowerMockito.spy(userService); + + // Mock有参数的私有方法 + PowerMockito.doReturn(false) + .when(spyService, "internalComplexValidation", "user", "pass"); + + boolean result = spyService.validateUser("user", "pass"); + + assertFalse(result); + } +} +``` + +**关键点说明**: + +- 使用`spy()`方法创建对象,这样未被Mock的方法会保持真实行为。 +- 使用`doReturn().when()`语法来Mock私有方法,需通过方法名字符串指定目标方法。 +- 可以通过`verifyPrivate()`验证私有方法的调用。 + +**最佳实践**:优先通过公共方法测试私有逻辑,仅在复杂算法验证等特殊场景下直接测试私有方法。 + +## final类与方法Mock + +final类和方法由于其不可继承性,在传统Mock框架中无法被Mock,但PowerMock完美解决了这个问题。 + +**场景示例**: + +```java +public final class FinalUtility { + public final String finalMethod() { + return "Final implementation"; + } + + public static final String staticFinalMethod() { + return "Static final implementation"; + } +} + +public class SomeService { + private FinalUtility utility = new FinalUtility(); + + public String useFinalClass() { + return utility.finalMethod() + "_processed"; + } +} +``` + +**测试代码**: + +```java +@RunWith(PowerMockRunner.class) +@PrepareForTest({FinalUtility.class, SomeService.class}) +public class SomeServiceTest { + + @Test + public void testFinalClassAndMethod() { + // 1. 创建final类的Mock对象 + FinalUtility mockUtility = PowerMockito.mock(FinalUtility.class); + + // 2. 预设final方法行为 + PowerMockito.when(mockUtility.finalMethod()).thenReturn("Mocked final"); + + // 3. 当创建真实对象时返回Mock对象 + PowerMockito.whenNew(FinalUtility.class).withNoArguments().thenReturn(mockUtility); + + // 4. 测试 + SomeService service = new SomeService(); + String result = service.useFinalClass(); + + assertEquals("Mocked final_processed", result); + } + + @Test + public void testStaticFinalMethod() { + // Mock静态final方法 + PowerMockito.mockStatic(FinalUtility.class); + PowerMockito.when(FinalUtility.staticFinalMethod()).thenReturn("Mocked static final"); + + assertEquals("Mocked static final", FinalUtility.staticFinalMethod()); + } +} +``` + +**底层原理**:PowerMock通过修改字节码,去除了final方法的final标识符,从而允许Mock操作。 + +## 构造函数Mock + +当方法内部直接通过new创建对象时,传统Mock难以介入,PowerMock的构造函数Mock功能为此提供了解决方案。 + +**场景示例**: + +```java +public class DatabaseConnection { + private String connectionString; + + public DatabaseConnection(String connectionString) { + this.connectionString = connectionString; + // 可能包含复杂的初始化逻辑 + } + + public boolean execute(String sql) { + // 执行SQL逻辑 + return true; + } +} + +public class UserRepository { + public boolean saveUser(String username) { + // 在方法内部直接创建依赖对象 + DatabaseConnection connection = new DatabaseConnection("jdbc:mysql://localhost:3306/test"); + return connection.execute("INSERT INTO users VALUES ('" + username + "')"); + } +} +``` + +**测试代码**: + +```java +@RunWith(PowerMockRunner.class) +@PrepareForTest(UserRepository.class) +public class UserRepositoryTest { + + @Test + public void testConstructorMock() throws Exception { + // 1. 创建Mock对象 + DatabaseConnection mockConnection = PowerMockito.mock(DatabaseConnection.class); + + // 2. 预设构造函数行为 + PowerMockito.whenNew(DatabaseConnection.class) + .withParameterTypes(String.class) + .withArguments("jdbc:mysql://localhost:3306/test") + .thenReturn(mockConnection); + + // 3. 预设方法行为 + PowerMockito.when(mockConnection.execute(Mockito.anyString())).thenReturn(true); + + // 4. 执行测试 + UserRepository repository = new UserRepository(); + boolean result = repository.saveUser("testuser"); + + // 5. 验证 + assertTrue(result); + PowerMockito.verifyNew(DatabaseConnection.class) + .withArguments("jdbc:mysql://localhost:3306/test"); + } +} +``` + +**关键点说明**: + +- `whenNew()`用于拦截构造函数调用。 +- `withParameterTypes()`和`withArguments()`用于精确匹配构造函数。 +- 需要使用`verifyNew()`验证构造函数调用。 + +**应用场景**:适用于测试遗留代码中在方法内部直接实例化依赖对象的情况。 + +## 静态代码块处理 + +静态代码块在类加载时执行,可能包含不愿在测试中运行的代码(如初始化昂贵资源),PowerMock可以抑制静态代码块的执行。 + +**示例**: + +```java +public class ConfigurationLoader { + static { + // 静态代码块,可能包含昂贵的初始化操作 + loadConfigurationFromRemote(); + } + + private static void loadConfigurationFromRemote() { + // 模拟昂贵的初始化 + throw new RuntimeException("不应该在测试中执行"); + } + + public static String getConfig(String key) { + return "value"; + } +} +``` + +**测试代码**: + +```java +@RunWith(PowerMockRunner.class) +@PrepareForTest(ConfigurationLoader.class) +public class ConfigurationLoaderTest { + + @Test + public void testSuppressStaticInitializer() throws Exception { + // 抑制静态代码块执行 + PowerMockito.suppress(PowerMockito.method(ConfigurationLoader.class, "loadConfigurationFromRemote")); + + // 现在可以安全测试,静态代码块不会执行 + assertNotNull(ConfigurationLoader.getConfig("testkey")); + } +} +``` + +# PowerMock最佳实践与注意事项 + +## 谨慎使用PowerMock + +虽然PowerMock功能强大,但过度使用可能是代码设计问题的信号。**以下是一些使用原则**: + +- **优先考虑重构**:如果代码中大量使用PowerMock,应该考虑重构代码以提高可测试性。例如,将静态方法改为实例方法,通过依赖注入解耦等。 +- **仅用于遗留代码**:在新项目中,优先通过良好设计避免使用PowerMock,仅在处理难以修改的遗留代码时大量使用。 +- **隔离使用**:将使用PowerMock的测试类单独放置,防止影响其他测试的执行效率。 + +## 性能优化建议 + +PowerMock由于使用自定义类加载器和字节码操作,会对测试执行时间产生显著影响。以下是一些优化建议: + +- **最小化@PrepareForTest**:只将确实需要Mock的类放入注解中,减少字节码操作的范围。 +- **合理使用Mockito**:对于常规Mock场景,仍然使用Mockito,仅在必要时使用PowerMock。 +- **避免过度Mock**:不要Mock系统类或简单值对象,这会给测试带来不必要的复杂性。 + +## 版本选择与兼容性 + +**版本兼容性**:PowerMock与Mockito、JUnit的版本兼容性非常重要。以下是推荐组合: + +- PowerMock 2.x + Mockito 2.x + JUnit 4.12+ +- 避免混合使用不兼容的版本 + +**JUnit 5支持**:截至目前,PowerMock不支持JUnit 5,这是选择测试框架时需要考虑的因素。 + +## 常见问题排查 + +**类加载器冲突**:使用`@PowerMockIgnore`注解排除冲突的包。 + +```java +@PowerMockIgnore({"javax.management.*", "javax.net.ssl.*"}) +``` + +**版本冲突**:确保所有Mock相关库的版本兼容。 + +**静态方法验证失败**:记住每次验证静态方法调用时都要先调用`verifyStatic()`。 + +# 总结 + +PowerMock解决了传统Mock框架无法处理的棘手问题。通过字节码操作技术,PowerMock能够Mock静态方法、final类、私有方法和构造函数等"不可Mock"的元素。 + +**核心价值**: + +- 填补了Mockito的功能空白,完善了Java单元测试的工具链。 +- 特别适用于处理遗留代码和第三方库的测试问题。 +- 通过提高代码覆盖率来提升软件质量。 + +**适用边界**: + +- 不是所有场景都适合使用PowerMock,新项目应优先考虑良好的代码设计。 +- 在测试性能和代码可维护性之间需要权衡。 +- 建议将使用范围控制在确实必要的复杂场景中。 + +希望本文能帮助你在实际项目中更好地使用PowerMock。如果你有任何问题或经验分享,欢迎在评论区留言交流! \ No newline at end of file From 22bb3e91efa50eaf5ecd5f6581138e09ce02ce73 Mon Sep 17 00:00:00 2001 From: BookSea <65435519+BookaiCode@users.noreply.github.com> Date: Mon, 24 Nov 2025 16:27:52 +0800 Subject: [PATCH 07/12] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8994a87..fd9e04b 100644 --- a/README.md +++ b/README.md @@ -186,7 +186,7 @@ - [实战Arthas:常见命令与最佳实践](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247488559&idx=1&sn=4b5003cb33446ab4a6173285fe9d83d3&chksm=cf8467eaf8f3eefc033de8f63cba9f0d7b2b5eb0ccfb5209f458a9ab447367b34954f296638b#rd) - [Maven实战](https://mp.weixin.qq.com/s/ErtWrRNzjJcR2ettUhAxsQ) - [不用Mockito写单元测试?你可能在浪费一半时间](https://mp.weixin.qq.com/s/NICubD9Yq0pn6qwpVIznfg) -- [用好PowerMock,轻松搞定那些让你头疼的单元测试]() +- [用好PowerMock,轻松搞定那些让你头疼的单元测试](https://mp.weixin.qq.com/s/rWIjqJKBQOe72RWW6qyJmA) ### :bulb: 资源 ### From fbde8b4601e69d278c441186add462aa0d3038b7 Mon Sep 17 00:00:00 2001 From: shuhaizheng Date: Mon, 29 Dec 2025 20:31:12 +0800 Subject: [PATCH 08/12] update --- README.md | 1 + ...275\240\347\216\251\350\275\254 RxJava.md" | 1707 +++++++++++++++++ 2 files changed, 1708 insertions(+) create mode 100644 "docs/md/\346\241\206\346\236\266/\345\223\215\345\272\224\345\274\217\347\274\226\347\250\213\344\270\215\345\217\252\346\234\211\346\246\202\345\277\265\357\274\201\344\270\207\345\255\227\351\225\277\346\226\207 + \344\273\243\347\240\201\347\244\272\344\276\213\357\274\214\346\211\213\346\212\212\346\211\213\345\270\246\344\275\240\347\216\251\350\275\254 RxJava.md" diff --git a/README.md b/README.md index fd9e04b..f08e788 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,7 @@ ### :date: 框架 ### - [本地缓存无冕之王Caffeine Cache](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247486885&idx=1&sn=37c7a9461402bd97822295cf51361777&chksm=cf847e60f8f3f776eb3b477decfbac55dc8b7ae1cf607ef68fbee89dbe02d40a800a92fabec7#rd) +- [响应式编程不只有概念!万字长文 + 代码示例,手把手带你玩转 RxJava](https://mp.weixin.qq.com/s/r0DJiOxR8wnZZ6tIKrSPzg) ### :fire: 架构设计 ### diff --git "a/docs/md/\346\241\206\346\236\266/\345\223\215\345\272\224\345\274\217\347\274\226\347\250\213\344\270\215\345\217\252\346\234\211\346\246\202\345\277\265\357\274\201\344\270\207\345\255\227\351\225\277\346\226\207 + \344\273\243\347\240\201\347\244\272\344\276\213\357\274\214\346\211\213\346\212\212\346\211\213\345\270\246\344\275\240\347\216\251\350\275\254 RxJava.md" "b/docs/md/\346\241\206\346\236\266/\345\223\215\345\272\224\345\274\217\347\274\226\347\250\213\344\270\215\345\217\252\346\234\211\346\246\202\345\277\265\357\274\201\344\270\207\345\255\227\351\225\277\346\226\207 + \344\273\243\347\240\201\347\244\272\344\276\213\357\274\214\346\211\213\346\212\212\346\211\213\345\270\246\344\275\240\347\216\251\350\275\254 RxJava.md" new file mode 100644 index 0000000..465bc2f --- /dev/null +++ "b/docs/md/\346\241\206\346\236\266/\345\223\215\345\272\224\345\274\217\347\274\226\347\250\213\344\270\215\345\217\252\346\234\211\346\246\202\345\277\265\357\274\201\344\270\207\345\255\227\351\225\277\346\226\207 + \344\273\243\347\240\201\347\244\272\344\276\213\357\274\214\346\211\213\346\212\212\346\211\213\345\270\246\344\275\240\347\216\251\350\275\254 RxJava.md" @@ -0,0 +1,1707 @@ +# Reactive Streams 介绍 + +在聊 Reactive Streams 之前,先了解一下 Reactive Programming(反应式/响应式编程)。为了解决异步编程中出现的各种问题,程序员们提出了各种的思路去解决这些问题,这些解决问题的方式、方法,手段就可以叫做 Reactive Programming。 + +Reactive Programming 是一种编程思想,类似面向对象,函数式编程。 + +本质上是对数据流或某种变化做出的反应,这个变化什么时候触发是未知的,所以他是一种基于异步、回调的方式在处理问题。 + +当越来越多的程序员,开始使用这种编程思想时,需要一些大佬来统一一个思想规范。所以国外的几个大佬公司启动了 Reactive Streams 项目。Netflix、Pivotal、Lightbend 联合来为异步流处理提供标准,规范。 + +Reactive Streams 翻译过来就是响应式/反应式流。**其实是一种基于异步流处理的标准化规范,目的是在使用流处理时更加可靠,高效和响应式。** + +# Java 层面的 Reactive Streams + +基于这个规范的实现很多,比如三方库中比较出名的 RxJava,Reactor 等等。 + +但是 JDK8 版本中,Java 已经有了 CompletableFuture 的支撑,我们可以将大量的异步任务做好编排。但是在 JDK8 版本中的 CompletableFuture 依然有很多特性无法支撑。所以在 JDK9,CompletableFuture 做了很多的更新,比如支持延迟,超时,子类化之类的功能。 + +这时,咱们会发现,其实 CompletableFuture 已经可以去支撑做一些异步编程的操作了。但是为什么很多大公司依然还是使用 RxJava,Reactor 这种三方依赖库呢? + +问题在于,大多数的时候,咱们采用异步编程处理的任务并不是非常复杂的。这个时候,咱们确实不需要去使用 Reactive Streams 反应流的框架。如果系统越来越复杂,或者你处理的业务本身就是及其复杂的那种,你就要去写一个让人头皮发麻的代码了。随着时间的推移,这种代码会变成非常难以维护。 + +其次 CompletableFuture 并不是真正的基于 Reactive Streams 去实现。CompletableFuture 描述的是单次执行的结果。尽管可以通过各种方法将异步任务之间构建成一串任务组成的流程图,本质上依然是单次的结果。 + +反应式流,面向的是 Stream。 咱们 Java 中的 Stream API 更类似 Reactive Streams 的思想。Stream API 是同步阻塞的。 + +最经典的就是 CompletableFuture 无法处理 Reactive Streams 中的一个核心概念,Back Pressure(背压,反压,回压),比如在上下游承载能力不同时,比如下游玩不转了,需要告知上游采取一些策略去解决。CompletableFuture 明显无法处理这种。 + +其次还有 Java 中提供的回调,Future 机制在实现响应式编程中,问题和缺点都比较难处理。有个比较出名的概念叫做 Callback Hell(回调地狱)。简单来说就是回调里面套回调,虽然将子过程做到解耦,但是随着业务的负责,回调代码的可读性、复杂性就大大的增加,这个就是回调地狱。 + +所以,咱们需要一套框架或者说类库来实现真正响应式流,大概需要几个特性: + +* 支持将异步任务做封装以及组装,需要 API 对异步任务进行包装,并且需要很多子任务来对异步操作进行链式组装,过程中包括过滤,异常处理,超时等等操作。 +* 减少异步任务的嵌套,减少代码的复杂性,增加可读性,避免 Callback Hell 这种及其复杂恶心的代码。 +* 支持背压 Back Pressure,也就需要有上游和下游的概念,可以做到协商处理数据流的速度。 + +# Java 层面 Reactive Steams 的 API + +首先 Reactive Steams 响应流实现方式其实是基于观察者模式的扩展,同时也能看到发布订阅模式,责任链模式等等。 + +整个 Reactive Steams 流程大致如下。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMTgibXaD43CfueC3XMT1iaUB1ovAJ9LveK6iaw8YUyCicia79IzCJWLHsPf9CS8o2bmxSOQt12GSDRKvA/640?wx_fmt=png&from=appmsg) + +直接在 JDK9 版本之上查看 Doug Lee 提供的 Flow 类。 + +在 Flow 类中,提供了核心的四个接口:Publisher,Subscriber,Subscription,Processor + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMTgibXaD43CfueC3XMT1iaUBTVSOkgjPOw4pnqTOrpwxic8X9o0Vtb09BUdP6jVCj41Yj1CVAqjexWA/640?wx_fmt=png&from=appmsg) + +Publisher:Publisher 是函数式接口,负责发布数据的。 Publisher 内部有一个方法 subscribe 方法,去和具体的订阅者绑定关系。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMTgibXaD43CfueC3XMT1iaUBxrb5DSib2xeqEeItcB7mpibkSCjZhseBSBHzSiciaNZa4BwKZYB5zmupFg/640?wx_fmt=png&from=appmsg) + +Subscriber:Subscriber 是订阅者,负责订阅,消费数据。四个方法: + +- onSubscribe:订阅成功后触发,并且表明可以开始接收发布者的数据元素了。 +- onNext:每次获取到发布者的数据元素都会执行 onNext。 +- onError:接收数据元素时,出现异常等问题时,走 onError。 +- onComplete:当指定接收的元素个数搞定后,触发 onComplete。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMTgibXaD43CfueC3XMT1iaUBJhzfo9CnDJicVedPtuy4Pp9Ivp8DcdgjH6v6dwvLoUwicSp88dfrP6rw/640?wx_fmt=png&from=appmsg) + +Subscription:发布者和订阅者是基于 Subscription 关联的。当建立了订阅的关系后,发布者会将 Subscription 传递给订阅者。订阅者指定获取元素的数量和取消订阅操作,都要基于 Subscription 去操作。提供了两个方法: + +- request:订阅者要获取的元素个数。 +- cancel:取消订阅,当前的订阅者不接收当前发布者的元素。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMTgibXaD43CfueC3XMT1iaUBhukW4Q6HRW1KPAuUONjNnYGDHDZNG1Cia3aaWSyxNHfNZu0zsTdZibWw/640?wx_fmt=png&from=appmsg) + +Processor:Processor 继承了 Publisher 和 Subscriber,即是发布者也是订阅者。Processor 一般作为数据的中转,订阅者处理完数据元素,可以再次发给下一个订阅者。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMTgibXaD43CfueC3XMT1iaUBGeW651DJBGcBsicB0QOgcMeBwYpE3zYb9Pbfk3vQhEibicNuvNFejlmqA/640?wx_fmt=png&from=appmsg) + +这四个接口很重要,是 Reactive Streams 的规范,但是可以明显的看到,内部没有具体的内容实现。 + +这里就类似 JDBC 这种规范,规范在 JDK9 中提出来了,想实现,可以基于当前的这四个接口再做具体的逻辑处理以及实现的细节。 + +# Java 层面 Reactive Steams 基本操作 + +咱们测试 Java 中的 Flow 里提供的 API 时,就是走最基本的操作。 + +其中 Processor 不需要重写,玩最基本的操作,不去做订阅者和发布者的转换。 + +其次 Subscription 也不需要重写,这东西就是提供了订阅者指定订阅的消息个数,以及取消的操作。 + +然后 Publisher 需要重写,但是 JDK 中已经提供了一个 Publisher 的实现,SubmissionPublisher,可以直接使用。 + +最后,Subscriber 需要咱们自己重写,指定好订阅消息的个数,已经消费的一些逻辑 + +```java +import java.util.concurrent.Flow; + + +public class MySubscriber implements Flow.Subscriber { + + @Override + // 绑定好订阅关系后,就会触发这个方法 + public void onSubscribe(Flow.Subscription subscription) { + subscription.request(10); + } + + @Override + public void onNext(Integer item) { + System.out.println(Thread.currentThread().getName() + ":接收到数据流:" + item); + } + + @Override + public void onError(Throwable throwable) { + System.out.println(Thread.currentThread().getName() + ":接收消息出现异常:" + throwable.getMessage()); + } + + @Override + public void onComplete() { + System.out.println(Thread.currentThread().getName() + ":当前订阅者要求接收的消息全部处理完毕。"); + } +} +``` + +直接使用 SubmissionPublisher 测试整体效果 + +```java +public static void main(String[] args) { + // 只有一个工作线程的线程池 + ExecutorService executor = Executors.newFixedThreadPool(1); + // 指定缓冲区的大小 + int maxBufferCapacity = 5; + + // 需要指定两个参数 + // 第一个参数需要传递一个线程池,指定订阅者使用的线程 + // 第二个参数,需要指定一个缓冲区,发布者发布消息后,消息会扔到缓冲区里。 + SubmissionPublisher publisher = new SubmissionPublisher<>(executor,maxBufferCapacity); + + // 绑定订阅者 + MySubscriber subscriber = new MySubscriber(); + publisher.subscribe(subscriber); + + // 发布消息 + for (int i = 0; i < 10; i++) { + System.out.println(Thread.currentThread().getName() + ":发布消息:" + i); + publisher.submit(i); + } + + // 释放资源 + publisher.close(); + executor.shutdown(); +} +``` + +结果输出如下: + +``` +main:发布消息:0 +main:发布消息:1 +main:发布消息:2 +main:发布消息:3 +main:发布消息:4 +main:发布消息:5 +main:发布消息:6 +main:发布消息:7 +main:发布消息:8 +main:发布消息:9 +pool-1-thread-1:接收到数据流:0 +pool-1-thread-1:接收到数据流:1 +pool-1-thread-1:接收到数据流:2 +pool-1-thread-1:接收到数据流:3 +pool-1-thread-1:接收到数据流:4 +pool-1-thread-1:接收到数据流:5 +pool-1-thread-1:接收到数据流:6 +pool-1-thread-1:接收到数据流:7 +pool-1-thread-1:接收到数据流:8 +pool-1-thread-1:接收到数据流:9 +pool-1-thread-1:当前订阅者要求接收的消息全部处理完毕。 +``` + +- **缓冲区:** 缓冲区就是发布者和订阅者之间的一块内存,类似线程池中的阻塞队列,可以将消息扔到这个缓存区里。其次咱们设置的缓冲区大小是 5,但是发现 get 出来的时候,5 被替换为了 8。这是因为 SubmissionPublisher 为了更有效的使用内存,默认会基于 roundCapacity 方法将传递的缓冲区大小替换为 2 的 n 次幂。 +- **背压效果:** 当订阅者指定的消息已经全部处理完毕后,发布者最多只能发布缓冲区大小个数的消息,剩下的内容会基于背压的效果直接暂时不发送。 +- **onComplete:** 需要发布者做了close 操作,确认了发布者已经将消息全部发送,并且订阅者也已经将全部的消息处理完毕后,才会触发 onComplete。 +- **Subscription:** 订阅者可以在 onNext 或者其他方法中动态的使用 subscription 去指定后续需要几个消息订阅,以及是否需要取消订阅消息等操作。 + +# Reactive Steams 落地体验 + +## 回调地狱问题 + +前面的方式大致了解了 JDK9 中更新的 Reactive Streams 的规范,咱们实现也仅仅是看到了发布订阅和回压的效果。并没有看到如何解决回调地狱的问题。咱们可以通过 Spring5 官网提供的一个例子,来体验一下 CallBack Hell 回调地狱带来的问题。后面再根据三方的实现来看一下基于 Reactive Streams 实现后效果如何。这里基本是根据伪代码走的。 + +例子:在用户的 UI 页面上,展示当前用户最喜欢的 Top5 的商品详情。这里会根据用户的 ID 去查询当前用户 Top5 商品的ID,如果 ID 可以查询到之后再根据商品的 ID 去查询商品的详情。如果当前用户 ID 查询的结果不存在喜欢的 Top 商品,没有的话,通过推荐服务查询 Top5 的商品信息。展示给用户。 + +当前例子需要三个服务的支撑: + +* 根据用户 ID 查询用户的 Top5 商品ID。 +* 根据 Top5 商品ID查询商品详情。 +* 调用推荐服务,获取5个商品详情。 + +基于 Java 最原生的异步编程方式,实现上述操作,来看看到底什么是回调地狱。。。 + +商品详情实体类: + +```java +@Data +public class Fav { + + private String itemId; + + private String itemName; + + private String itemDetail; + +} +``` + +准备回调方法,拿到结果后触发 + +```java +public interface Callback { + + void onSuccess(T t); + + void onError(Throwable throwable); + +} +``` + +准备了访问三个服务的 Service 接口 + +```java +public interface UserService { + + /** + * 根据用户Id查询用户的Top5商品Id + * @param userId + * @param list + */ + void getFav(String userId, Callback> list); + +} + +public interface ItemService { + + /** + * 根据商品Id查询商品的详情 + * + * @param itemId + * @param callback + */ + void getDetail(String itemId, Callback callback); +} + +public interface SuggestionService { + + /** + * 调用推荐服务,获取推荐商品 + * @param favs + */ + void getSuggestion(Callback> favs); +} +``` + +准备了响应数据的 UI 线程工具以及响应方法 + +```java +public class UiUtils { + + public static void submitOnUiThread(Runnable runnable){ + // 线程池中的线程做响应的操作……………… + } + + + public static void show(Object obj){ + // 利用UI线程展示具体数据 + } + + public static void error(Object obj){ + // 出现错误响应的内容 + } + +} +``` + +完成了 Controller 中的异步编程效果 + +```java +@RestController +public class CallBackHellController { + + @Autowired + private UserService userService; + + @Autowired + private ItemService itemService; + + @Autowired + private SuggestionService suggestionService; + + + @GetMapping("/callbackhell") + public void callbackHell(String userId){ + //1、调用用户服务,查询Top5商品Id + userService.getFav(userId, new Callback>() { + @Override + public void onSuccess(List list) { + // 已经查询到商品Id,但是不知道是否有值 + if (list.isEmpty()){ + // 3、用户没有Top5商品Id,通过推荐服务查询推荐商品详情 + suggestionService.getSuggestion(new Callback>(){ + @Override + public void onSuccess(List favs) { + // 推荐服务查询到了商品详情,响应即可 + UiUtils.submitOnUiThread(() -> { + favs.stream().limit(5).forEach(UiUtils::show); + }); + } + @Override + public void onError(Throwable throwable) { + UiUtils.error(throwable); + } + }); + + } + else{ + // 2、通过用户查询到了Top5商品Id,通过商品Id查询商品详情 + list.stream().limit(5).forEach(itemId -> itemService.getDetail(itemId,new Callback(){ + + @Override + public void onSuccess(Fav fav) { + // 查询到了商品详情,利用UI线程,给客户端响应数据 + UiUtils.submitOnUiThread(() -> UiUtils.show(fav)); + } + + @Override + public void onError(Throwable throwable) { + // 出现异常了。 + UiUtils.error(throwable); + } + })); + } + } + @Override + public void onError(Throwable throwable) { + // 出现异常了。 + UiUtils.error(throwable); + } + }); + + + } + +} +``` + +## 解决回调地狱问题 + +这里为了解决回调地狱问题,需要一个 Reactor 的依赖来帮助咱们实现异步编程。 + +需要导入依赖 + +```xml + + + io.projectreactor + reactor-core + 3.7.7 + +``` + +不能再使用之前的 Callback 方式了。需要使用 reactor 提供的 Flux,并且这种链式操作会更直观,也更好维护。就只需要修改三个服务对应的 Service。 + +```java +public interface UserService { + + /** + * 根据用户ID查询Top5商品ID + * @param userId + * @return + */ + Flux> getFav(String userId); + +} + +public interface ItemService { + + + /** + * 根据商品ID查询商品详情 + * @param itemId + * @return + */ + Flux getDetail(String itemId); + +} + +public interface SuggestionService { + + /** + * 获取推荐的商品详情 + * @return + */ + Flux> getSuggestion(); + +} +``` + +然后就可以利用 Flux 提供的 API 来解决之前回调地狱的问题。 + +```java +@RestController +public class ReactorCallbackController { + + @Autowired + private UserService userService; + + @Autowired + private ItemService itemService; + + @Autowired + private SuggestionService suggestionService; + + + @GetMapping("reactorcallback") + public void reactorCallback(String userId){ + userService + .getFav(userId) // 根据用户Id查询Top5商品Id + .flatMap(itemService::getDetail) // 根据商品ID查询商品详情 + .switchIfEmpty(suggestionService.getSuggestion()) // 如果前面为null,这里通过推荐服务查询商品详情 + .take(5) // 获取前5个数据 + .publishOn(UiUtils.reactorOnUiThread()) // 使用Ui线程 + .subscribe(UiUtils::show,UiUtils::error); // 成功走show,失败走error + } + +} +``` + +## CompletableFuture 的异步编程 + +Future 的形式相比 Callback Hell 效果要好一些,虽然 JDK8 和 9 都对 CompletableFuture 做了各种优化,但是他的表现还是不太好。多个Future在嵌套时,可读性还是比较差的。并且 CompletableFuture 不存在什么回压,或者是延迟调用的功能。 + +现在借助 CompletableFuture 来实现一个场景。 + +1. 获取一个用户ID的列表。 +2. 通过用户ID分别获取他的名字以及统计信息。(希望这两个操作是并行执行的) +3. 当两个信息都获取到之后,封装成一个普通字符串即可。 +4. 响应数据,最后拿到结果(输出一下)。 + +实现代码 + +```java +public class GetNameAndStatTestByCF { + + public static void main(String[] args) { + // 1、获取一组用户ID列表 + CompletableFuture> idList = getID(); + CompletableFuture> dataCompletableFuture = idList.thenComposeAsync(ids -> { + Stream> resultStream = ids.stream().map(id -> { + // 2、并行基于ID查询名称信息 + CompletableFuture nameTask = getName(); + // 2、并行基于ID查询统计信息 + CompletableFuture statTask = getStat(); + // 让两个查询名称信息和查询统计信息操作并行执行 + return nameTask.thenCombineAsync(statTask, (name, stat) -> { + // 3、拿到信息组装 + return "Name:" + name + ",Stat:" + stat; + }); + }); + // 将resultStream转换成一个数组 + List> resultList = resultStream.toList(); + // 全部的任务封装起来 + CompletableFuture allDone = CompletableFuture.allOf(resultList.toArray(new CompletableFuture[]{})); + // 执行全部任务 + return allDone.thenApplyAsync(v -> resultList.stream() + .map(CompletableFuture::join) + .collect(Collectors.toList())); + }); + + // 4、获取全部的组件信息后响应客户端(输出) + List data = dataCompletableFuture.join(); + System.out.println(data); + } + + // 模拟zz服务获取统计信息 + private static CompletableFuture getStat() { + return CompletableFuture.supplyAsync(() -> 666); + } + + // 模拟yy服务获取名称信息 + private static CompletableFuture getName() { + return CompletableFuture.supplyAsync(() -> "张三"); + } + + // 模拟xx服务,获取一组用户ID + private static CompletableFuture> getID() { + return CompletableFuture.supplyAsync(() -> { + // 模拟查询三方服务 + List list = new ArrayList<>(); + list.add("1"); + list.add("2"); + list.add("3"); + return list; + }); + } + +} +``` + +## 解决 CompletableFuture 的问题 + +CompletableFuture 可以实现一些简单的异步编程,但是可看性和维护性以后后期的扩展都需要对整体代码做比较大成本的维护。依然采用 Reactor 来实现一个一模一样的逻辑,再看代码效果。 + +```java +public class GetNameAndStatByReactor { + + public static void main(String[] args) { + // 1、获取一组用户ID列表 + Flux idFlux = getId(); + + Flux result = idFlux.flatMap(id -> { + // 2、并行基于ID查询名称信息 + Flux nameFlux = getName(id); + // 2、并行基于ID查询统计信息 + Flux statFlux = getStat(id); + // 俩任务并行处理完毕,触发3 + return nameFlux.zipWith(statFlux, (name, stat) -> { + // 3、拿到信息组装 + return "Name:" + name + ",Stat:" + stat; + }); + }); + Mono> listMono = result.collectList(); + List info = listMono.block(); + // 4、获取全部的组件信息后响应客户端(输出) + System.out.println(info); + } + + private static Flux getStat(String id) { + // 会查询三方服务,然后封装结果 + return Flux.just(888); + } + + private static Flux getName(String id) { + // 会查询三方服务,然后封装结果 + return Flux.just("张三"); + } + + + private static Flux getId() { + // 会查询三方服务,然后封装结果 + return Flux.just("1","2","3"); + } +} +``` + +# RxJava2 实现异步编程 + +RxJava 是一个小框架,或者是依赖库。在 RxJava 的1.x版本中,它并不基于 Reactive Streams 去实现的。没有关系,因为 RxJava 的 2 版本,就是基于Reactive Streams 实现的了。 + +使用 RxJava 巨简单,因为作者想将 RxJava 尽量做到轻量级,就一个依赖。 + +```xml + + + io.reactivex.rxjava2 + rxjava + 2.2.21 + +``` + +## RxJava2 的入门操作 + +获取一个 Person 对象的集合,将 Person 集合中的所有年龄大于10岁的 Person 对象筛选出来,并输出他的名字。 + +采用 RxJava 来实现一下: + +```java +public class Demo { + + public static void main(String[] args) { + //1、获取Person对象集合 + List personList = getPersonList(); + + //2、完成上面要求的操作 + //2.1、将person集合转换为RxJava的流 + Flowable.fromArray(personList.toArray(new Person[]{})) + //2.2 过滤年龄大于10岁的 + .filter(person -> person.getAge() > 10) + //2.3 获取筛选后的Person名称 + .map(person -> person.getName()) + //2.4 输出Name + .subscribe(System.out::println); + } + + private static List getPersonList() { + List personList = new ArrayList<>(); + personList.add(new Person("大娃",5)); + personList.add(new Person("二娃",7)); + personList.add(new Person("三娃",9)); + personList.add(new Person("四娃",11)); + personList.add(new Person("五娃",13)); + return personList; + } +} +``` + +## RxJava2 的基础处理流程 + +在 RxJava 中有三个核心的角色 + +* 被观察者(Observable) +* 观察者(Observer) +* 订阅(Subscribe) + +```java +public class Demo2 { + + public static void main(String[] args) { + //1、构建Observable + Observable observable = Observable.create(emitter -> { + emitter.onNext("Hello"); + emitter.onNext("World"); + emitter.onComplete(); + }); + + //2、构建Observer + Observer observer = new Observer<>() { + @Override + public void onSubscribe(Disposable d) { + System.out.println("开始订阅"); + } + + @Override + public void onNext(String s) { + System.out.println("观察者:" + s); + } + + @Override + public void onError(Throwable e) { + e.printStackTrace(); + } + + @Override + public void onComplete() { + System.out.println("订阅结束"); + } + }; + + //3、订阅 + observable.subscribe(observer); + } + +} +``` + +## 创建操作符 + +### create + +Observable.create() 是手动创建 Observable 的方法,允许完全控制数据的发射、完成和错误处理。 + +```java +Observable observable = Observable.create(emitter -> { + emitter.onNext("Hello"); + emitter.onNext("World"); + emitter.onComplete(); +}); + +observable.subscribe( + item -> System.out.println("收到: " + item), + error -> System.err.println("错误: " + error), + () -> System.out.println("完成") +); + +// 输出: +// 收到: Hello +// 收到: World +// 完成 +``` + +### just + +just 用于创建一个发射固定数据的 Observable,数据是预定义的,发射后立即完成。 + +```java +Observable.just("Hello") + .subscribe(item -> + System.out.println("收到: " + item) + ); + +// 输出: +// 收到: Hello +``` + +### fromArray + +fromArray 用于从数组创建一个 Observable,按数组顺序发射所有元素。 + +```java +String[] fruits = {"Apple", "Banana", "Cherry", "Date"}; +Observable.fromArray(fruits) + .subscribe(fruit -> + System.out.print(fruit + " ") + ); + +// 输出: +// Apple Banana Cherry Date +``` + +### fromCallable + +fromCallable 用于从 Callable 创建 Observable,Callable 的返回值会被包装成 Observable 发射。 + +```java + public static void main(String[] args) { + Observable.fromCallable(() -> "计算结果: " + System.currentTimeMillis()) + .subscribe(System.out::println); + + // 输出: + // 计算结果: 1620000000000 + } +``` + +### timer + +timer 用于创建一个延迟指定时间后发射单个数据的 Observable,通常是 0L,然后结束。 + +```java + public static void main(String[] args) throws InterruptedException { + System.out.println("开始时间: " + System.currentTimeMillis()); + + Observable.timer(2, TimeUnit.SECONDS) + .subscribe(tick -> + System.out.println("触发时间: " + System.currentTimeMillis() + ",值: " + tick) + ); + + Thread.sleep(3000); + } +``` + +默认使用 `Schedulers.computation()`线程池计算,线程池中的线程是守护线程,如果主线程结束守护线程也会随之终止。 + +### interval + +interval() 方法用于创建一个 周期性定时发射的 Observable,它会按照指定的时间间隔无限期地发射递增的数字序列(从0开始)。 + +```java + public static void main(String[] args) throws IOException { + Observable.interval(2, TimeUnit.SECONDS) + .subscribe(aLong -> System.out.println(Thread.currentThread().getName() + ":" + aLong)); + + System.in.read(); + } +``` + +### intervalRange + +intervalRange() 是 interval() 的增强版本,用于创建一个有限次数的周期性发射的 Observable。它允许你指定起始值、发射次数、初始延迟和间隔时间。 + +```java + public static void main(String[] args) throws IOException { + Observable.intervalRange(100, 4, 0, 2, TimeUnit.SECONDS) + .subscribe(aLong -> System.out.println(Thread.currentThread().getName() + ":" + aLong)); + System.in.read(); + } +``` + +### range & rangeLong + +这两个方法用于创建一个发射连续整数序列的 Observable: + +- `range(start, count)`:发射 Integer 类型的连续整数。 +- `rangeLong(start, count)`:发射 Long 类型的连续整数。 + +```java + public static void main(String[] args) throws IOException { + Observable.range(0, 10) + .subscribe(integer -> System.out.println(Thread.currentThread().getName() + ":" + integer)); + System.in.read(); + } +``` + +### never、error、empty + +这三个方法都是创建特殊的 Observable 的工厂方法,用于特定的场景。 + +* never:创建一个永远不会发射任何数据,也不会终止的 Observable。 +* error:创建一个立即发射错误的 Observable。 +* empty:创建一个立即完成但不发射任何数据的 Observable。 + +```java + public static void main(String[] args) { + // Observable.never() + // Observable.error(new RuntimeException("error事件")) + Observable.empty() + .subscribe(new Observer() { + @Override + public void onSubscribe(Disposable d) { + System.out.println("开始订阅"); + } + + @Override + public void onNext(Object s) { + System.out.println("观察者:" + s); + } + + @Override + public void onError(Throwable e) { + System.out.println("出现异常:" + e.getMessage()); + } + + @Override + public void onComplete() { + System.out.println("订阅结束"); + } + }); + } +``` + +## 转换操作符 + +转换操作符是 RxJava 中用于对 Observable 发射的数据进行变换、处理、组合的操作符,用于实现数据流的各种转换逻辑。 + +### map + +map() 是 RxJava 中最基本、最常用的转换操作符,用于对 Observable 发射的每个元素进行一对一转换。 + +简单来说:输入一个值,输出另一个值(1进1出)。 + +```java + public static void main(String[] args) { + Observable.just(1, 2, 3, 4, 5) + .map(number -> "数字: " + number) + .subscribe(text -> System.out.println(text)); + } +``` + +### flatMap + +flatMap() 用于将每个发射项转换为 Observable,然后"扁平化"合并成一个 Observable。 +简单来说:输入一个值,可以输出任意多个值(1进N出)。 + +```java + public static void main(String[] args) { + Observable.just("A", "B") + .map(letter -> letter + "1") + .subscribe(result -> System.out.print(result + " ")); + // 输出: A1 B1 + + // flatMap: 1对多转换 + Observable.just("A", "B") + .flatMap(letter -> + Observable.just(letter + "1", letter + "2", letter + "3") + ) + .subscribe(result -> System.out.print(result + " ")); + // 输出: A1 A2 A3 B1 B2 B3 + } +``` + +### concatMap + +concatMap() 是 flatMap() 的顺序保持版本,它会严格按照原始顺序依次处理每个元素,只有前一个元素的 Observable 完成之后,才会处理下一个。 +简单来说:flatMap是并发处理,concatMap是串行处理。 + +```java + public static void main(String[] args) throws IOException { + Observable.just(3, 1, 2) // 注意顺序:3, 1, 2 + .flatMap(num -> + Observable.just(num) + .delay(num, TimeUnit.SECONDS) // 延迟对应的秒数 + ) + .subscribe(num -> System.out.println("flatMap: " + num)); + // 输出顺序:1 2 3(谁先完成谁先输出) + + Observable.just(3, 1, 2) + .concatMap(num -> + Observable.just(num) + .delay(num, TimeUnit.SECONDS) + ) + .subscribe(num -> System.out.println("concatMap: " + num)); + // 输出顺序:3 1 2(严格保持原始顺序) + System.in.read(); + } +``` + +### buffer + +buffer() 用于将 Observable 发射的数据项收集到集合中,然后一次性发射这些集合,而不是单个发射。 +简单来说:把多个单独的数据"打包"成一批一起发射。 + +```java + public static void main(String[] args) { + Observable.range(1, 10) // 发射1-10 + .buffer(3) // 每3个一批 + .subscribe(batch -> + System.out.println("批次: " + batch) + ); + + // 输出: + // 批次: [1, 2, 3] + // 批次: [2, 4, 6] + // 批次: [7, 8, 9] + // 批次: [10] ← 最后一批不足3个 + } +``` + +### scan + +scan() 用于对 Observable 发射的数据进行累积计算,并发射每个中间结果。 +简单来说:像 Excel 里的累计求和,每来一个新数据,就与前一个结果计算,并输出当前累计值。 + +```java + public static void main(String[] args) { + Observable.just(1, 2, 3, 4, 5) + .scan(Integer::sum) + .subscribe(result -> System.out.print(result + " ")); + + // 输出: + // 1 3 6 10 15 + + // 计算过程: + // 初始:无种子值,第一次直接发射1 + // 1 + 2 = 3 + // 3 + 3 = 6 + // 6 + 4 = 10 + // 10 + 5 = 15 + } +``` + +### window + +window() 用于将 Observable 发射的数据分组到多个子 Observable 中,然后发射这些子 Observable 而不是单个数据项。 +简单来说:创建多个"窗口",每个窗口是一个 Observable,将数据分配到不同窗口中。 + +```java + public static void main(String[] args) { + // buffer: 直接发射List + Observable.range(1, 5) + .buffer(2) + .subscribe(list -> + System.out.println("buffer输出List: " + list) + ); + + // window: 发射Observable,需要进一步处理 + Observable.range(1, 5) + .window(2) + .flatMapSingle(Observable::toList // 需要手动转换 + ) + .subscribe(list -> + System.out.println("window输出List: " + list) + ); + + // 两者输出相同,但window更灵活: + // 输出: + // [1, 2] + // [3, 4] + // [5] + } +``` + +window 和 buffer 的区别就是: + +* window 返回的是被观察者的集合。 +* buffer 返回的是数据的集合。 + +## 过滤操作符 + +过滤操作符用于从数据流中筛选出需要的数据,过滤掉不需要的数据。 + +### filter + +filter() 是 RxJava 中最基本的过滤操作符,用于根据指定条件筛选 Observable 发射的数据,只让满足条件的数据通过,不满足条件的被过滤掉。 + +对每个数据项进行判断,返回 true则通过,返回 false则丢弃。 + +```java + public static void main(String[] args) { + Observable.range(1, 10) // 发射1-10 + .filter(number -> number % 2 == 0) // 只保留偶数 + .subscribe(even -> System.out.print(even + " ")); + + // 输出: + // 2 4 6 8 10 + } +``` + +### ofType + +ofType() 是一个类型过滤操作符,用于过滤 Observable 发射的数据,只保留指定类型的数据,其他类型的数据会被过滤掉。 +核心思想:只让指定类型的数据通过,相当于 filter(item -> item instanceof TargetType) 的简化版。 + +```java + public static void main(String[] args) { + Observable mixedObservable = Observable.just( + "Hello", // String + 123, // Integer + 45.6, // Double + "World", // String + true, // Boolean + 789 // Integer + ); + + // 只保留字符串类型 + mixedObservable + .ofType(String.class) + .subscribe(str -> + System.out.println("字符串: " + str) + ); + + // 输出: + // 字符串: Hello + // 字符串: World + } +``` + +### distinct + +distinct() 用于过滤 Observable 中重复的数据项,确保每个数据项只发射一次。 +核心思想:去重,保证发射的数据序列中不包含重复元素。 + +```java + public static void main(String[] args) { + Observable.just(1, 2, 2, 3, 3, 3, 4, 5, 5, 1) + .distinct() + .subscribe(num -> System.out.print(num + " ")); + + // 输出: + // 1 2 3 4 5 + // 注意:最后的 1 也被去重了 + } +``` + +### skip & skipLast + +- skip(n):跳过 Observable 发射的前 n 个数据项。 +- skipLast(n):跳过 Observable 发射的后 n 个数据项。 + +核心思想:skip是"跳过开头",skipLast是"跳过结尾"。 + +```java + public static void main(String[] args) { + Observable.create(emitter -> { + emitter.onNext(1); + emitter.onNext(2); + emitter.onError(new RuntimeException("中间出错")); + }) + .skipLast(1) + .subscribe( + num -> System.out.println("数据: " + num), + error -> System.err.println("错误: " + error.getMessage()), + () -> System.out.println("完成") + ); + + // 输出: + // 数据: 1 + // 错误: 中间出错 + } +``` + +### distinctUntilChanged + +distinctUntilChanged() 用于过滤掉连续重复的数据,只保留发生变化的数据。 +核心思想:去重,但只去重连续相同的值,不连续出现的相同值会被保留。 + +```java + public static void main(String[] args) { + Observable.just(1, 1, 2, 2, 1, 3, 3, 2, 2, 1) + .distinctUntilChanged() + .subscribe(num -> System.out.print(num + " ")); + + // 输出: + // 1 2 1 3 2 1 + + // 解释: + // 原始: 1, 1, 2, 2, 1, 3, 3, 2, 2, 1 + // 去重连续重复后: 1, 2, 1, 3, 2, 1 + // 注意:中间的 1 虽然出现过,但因为不连续,所以保留了 + } +``` + +### take + +take() 用于从 Observable 的开头取出指定数量的数据,然后完成。 +核心思想:只取前 N 个数据,之后的数据忽略,Observable 提前完成。 + +```java + public static void main(String[] args) { + Observable.range(1, 10) // 发射1-10 + .take(3) // 只取前3个 + .subscribe( + num -> System.out.print(num + " "), + error -> { + }, + () -> System.out.println("\n已完成") + ); + + // 输出: + // 1 2 3 + // 已完成 + // 注意:4-10 不会被发射 + } +``` + +### firstElement & lastElement & elementAt + +这三个操作符都用于从 Observable 中取出特定位置的单个元素: + +- **`firstElement()`**:取第一个元素 +- **`lastElement()`**:取最后一个元素 +- **`elementAt(index)`**:取指定索引位置的元素 + +```java + public static void main(String[] args) { + Observable.range(1, 5) // 1,2,3,4,5 + .firstElement() // 取第一个 + .subscribe( + num -> System.out.println("第一个: " + num), + error -> { + }, + () -> System.out.println("没有第一个元素") + ); + + // 输出: + // 第一个: 1 + } +``` + +## 组合操作符 + +组合操作符是 RxJava 中用于将多个 Observable 组合成一个 Observable 的操作符。它们处理多个数据流之间的关系,实现流的合并、连接、组合等操作。 + +### concat + +concat() 用于顺序连接多个 Observable,前一个 Observable 完成后,才开始发射下一个 Observable 的数据。 +核心思想:串行连接,保持顺序,像排队一样一个接一个。 + +```java + public static void main(String[] args) { + Observable first = Observable.just("A", "B", "C"); + Observable second = Observable.just("D", "E", "F"); + Observable third = Observable.just("G", "H"); + + Observable.concat(first, second, third) + .subscribe( + letter -> System.out.print(letter + " "), + error -> { + }, + () -> System.out.println("\n全部完成") + ); + + // 输出: + // A B C D E F G H + // 全部完成 + // 严格按照 first → second → third 的顺序 + } +``` + +### concatArray + +concatArray() 是 concat() 的数组版本,用于顺序连接多个 Observable(以数组形式提供),功能与 concat() 完全相同,只是参数形式不同。 + +```java + public static void main(String[] args) { + // 创建Observable数组 + Observable[] observables = new Observable[]{ + Observable.just("A", "B"), + Observable.just("C", "D", "E"), + Observable.just("F", "G") + }; + + // 使用 concatArray 连接 + Observable.concatArray(observables) + .subscribe(letter -> System.out.print(letter + " ")); + + // 输出: + // A B C D E F G + // 顺序连接所有数组中的Observable + } +``` + +### merge + +merge() 用于并发合并多个 Observable,将所有数据按实际到达时间顺序混合发射。 + +核心思想:并行执行,谁先到谁先出,像多车道合并成单车道。 + +```java + public static void main(String[] args) throws InterruptedException { + Observable fast = Observable.interval(100, TimeUnit.MILLISECONDS) + .map(i -> i + 100) // 100, 101, 102... + .take(3); + Observable slow = Observable.interval(200, TimeUnit.MILLISECONDS) + .map(i -> i + 200) // 200, 201, 202... + .take(3); + + Observable.merge(fast, slow) + .subscribe(num -> System.out.print(num + " ")); + + Thread.sleep(1000); + + // 输出(实际时间顺序): + // 100 101 200 102 201 202 + // 解释: + // 100ms: fast发射100 + // 200ms: slow发射200, fast发射101 + // 300ms: fast发射102 + // 400ms: slow发射201 + // 600ms: slow发射202 + } +``` + +### zip + +zip() 用于将多个 Observable 的数据按索引配对组合,像拉链一样一一对应。 + +核心思想:等待所有Observable都有数据,然后配对发射,以最短的Observable为准。 + +```java + public static void main(String[] args) { + Observable letters = Observable.just("A", "B", "C", "D"); + Observable numbers = Observable.just(1, 2, 3); + + Observable.zip( + letters, + numbers, + (letter, number) -> letter + number + ) + .subscribe(result -> System.out.print(result + " ")); + + // 输出: + // A1 B2 C3 + // 注意:D 被丢弃了,因为 numbers 只有3个 + + } +``` + +### startWith & startWithArray + +这两个操作符都用于在 Observable 发射数据之前,先发射一些指定的数据,就像给数据流添加"开场白"。 + +- startWith():添加单个元素或 Observable。 +- startWithArray():添加多个元素(可变参数)。 + +```java + public static void main(String[] args) { + // startWith: 添加单个元素 + Observable.just("B", "C") + .startWith("A") + .subscribe(letter -> System.out.print("startWith: " + letter + " ")); + + // startWithArray: 添加多个元素 + Observable.just("D", "E") + .startWithArray("A", "B", "C") + .subscribe(letter -> System.out.print("startWithArray: " + letter + " ")); + + // 输出: + // startWith: A B C + // startWithArray: A B C D E + } +``` + +### count + +count() 用于统计 Observable 发射的元素数量,返回一个发射单个计数值的 Observable。 + +核心思想:数一数发射了多少个元素。 + +```java + public static void main(String[] args) { + Observable.just("A", "B", "C", "D", "E") + .count() + .subscribe(count -> + System.out.println("元素数量: " + count) + ); + + // 输出: + // 元素数量: 5 + } +``` + +## 功能操作符 + +功能操作符是 RxJava 中用于辅助调试、监控、错误处理、资源管理和工具性功能的操作符。它们不直接处理数据流,而是提供辅助功能。 + +### delay + +delay() 用于延迟发射 Observable 的数据,可以延迟整个流,也可以延迟每个元素。 +核心思想:让数据"等一会儿"再发射。 + +```java + public static void main(String[] args) throws InterruptedException { + System.out.println("开始时间: " + System.currentTimeMillis()); + + Observable.just("A", "B", "C") + .delay(1, TimeUnit.SECONDS) // 延迟1秒 + .subscribe(item -> + System.out.println(System.currentTimeMillis() + ": " + item) + ); + + Thread.sleep(2000); + + // 输出: + // 开始时间: 1766569373459 + // 1766569374574: A + // 1766569374580: B + // 1766569374580: C + // 注意:A,B,C几乎同时发射,都延迟了1秒 + } +``` + +### subscribeOn & observerOn + +这两个操作符用于控制 Observable 在哪个线程上执行和观察,是 RxJava 线程调度的核心。 + +- **`subscribeOn()`**:指定 Observable 执行在哪个线程 +- **`observeOn()`**:指定 Observer 观察在哪个线程 + +subscribeOn 示例: + +```java + public static void main(String[] args) throws InterruptedException { + Observable.create(emitter -> { + System.out.println("执行线程: " + Thread.currentThread().getName()); + emitter.onNext("数据"); + emitter.onComplete(); + }) + .subscribeOn(Schedulers.io()) // 在IO线程执行 + .subscribe(data -> + System.out.println("接收线程: " + Thread.currentThread().getName()) + ); + + Thread.sleep(1000); + + // 输出: + // 执行线程: RxCachedThreadScheduler-1 + // 接收线程: RxCachedThreadScheduler-1 + // 注意:执行和接收都在IO线程 + } +``` + +observeOn 示例: + +```java + public static void main(String[] args) throws InterruptedException { + Observable.create(emitter -> { + System.out.println("执行线程: " + Thread.currentThread().getName()); + emitter.onNext("数据"); + emitter.onComplete(); + }) + .observeOn(Schedulers.io()) // 在IO线程接收 + .subscribe(data -> + System.out.println("接收线程: " + Thread.currentThread().getName()) + ); + + Thread.sleep(1000); + + // 输出: + // 执行线程: main + // 接收线程: RxCachedThreadScheduler-1 + // 注意:执行在主线程,接收在IO线程 + } +``` + +### retry + +retry 用于在 Observable 发生错误时自动重试,实现错误恢复机制,直到成功或达到重试上限。 + +```java + public static void main(String[] args) { + AtomicInteger attempt = new AtomicInteger(); + + Observable.create(emitter -> { + attempt.getAndIncrement(); + System.out.println("第" + attempt + "次尝试"); + if (attempt.get() < 3) { + emitter.onError(new RuntimeException("随机失败")); + } else { + emitter.onNext("成功"); + emitter.onComplete(); + } + }) + .retry(2) // 最多重试2次 + .subscribe( + data -> System.out.println("结果: " + data), + error -> System.err.println("最终失败: " + error.getMessage()) + ); + + // 输出: + // 第1次尝试 + // 第2次尝试 + // 第3次尝试 + // 结果: 成功 + // 尝试3次后成功(初始1次+重试2次) + } +``` + +### retryUntil + +retryUntil 用于自定义重试停止条件,当条件满足时停止重试。 + +```java + public static void main(String[] args) { + AtomicInteger attempt = new AtomicInteger(0); + Observable.create(emitter -> { + int count = attempt.incrementAndGet(); + System.out.println("第" + count + "次尝试"); + if (count < 3) { + emitter.onError(new RuntimeException("失败")); + } else { + emitter.onNext("成功"); + emitter.onComplete(); + } + }) + .retryUntil(() -> { + // 返回 true 时停止重试 + return attempt.get() >= 3; // 尝试3次后停止 + }) + .subscribe( + data -> System.out.println("结果: " + data), + error -> System.err.println("最终失败: " + error.getMessage()) + ); + + // 输出: + // 第1次尝试 + // 第2次尝试 + // 第3次尝试 + // 结果: 成功 + } +``` + +## 生命周期的功能操作符 + +很多功能操作符贯穿整个发布订阅的生命周期中。 + +### doOnSubscribe- 订阅时 + +```java + public static void main(String[] args) { + Observable.just("A", "B", "C") + .doOnSubscribe(disposable -> + System.out.println("有人订阅了!") + ) + .subscribe(); + // 输出: + // 有人订阅了! + } +``` + +### doOnNext - 发射每个元素时 + +```java + public static void main(String[] args) { + Observable.range(1, 3) + .doOnNext(num -> + System.out.println("即将发射: " + num) + ) + .map(num -> num * 2) + .doOnNext(num -> + System.out.println("发射后: " + num) + ) + .subscribe(); + + // 输出: + // 即将发射: 1 + // 发射后: 2 + // 即将发射: 2 + // 发射后: 4 + // 即将发射: 3 + // 发射后: 6 + } +``` + +### doAfterNext - 发射后执行 + +```java +Observable.just("A", "B", "C") + .doOnNext(item -> + System.out.println("发射前: " + item) + ) + .doAfterNext(item -> + System.out.println("发射后: " + item) + ) + .subscribe(item -> + System.out.println("接收: " + item) + ); + +// 输出: +// 发射前: A +// 接收: A +// 发射后: A +// 发射前: B +// 接收: B +// 发射后: B +// 发射前: C +// 接收: C +// 发射后: C +``` + +### doOnError - 出错时 + +```java + public static void main(String[] args) { + Observable.error(new RuntimeException("测试错误")) + .doOnError(error -> + System.err.println("捕获到错误: " + error.getMessage()) + ) + .onErrorReturnItem("默认值") + .subscribe(); + + // 输出: + // 捕获到错误: 测试错误 + } +``` + +### doOnComplete - 完成时 + +```java + public static void main(String[] args) { + Observable.just("任务1", "任务2") + .doOnComplete(() -> + System.out.println("所有任务都完成了!") + ) + .subscribe(); + + // 输出: + // 所有任务都完成了! + } +``` + +### doOnTerminate - 完成/错误前执行 + +```java + public static void main(String[] args) { + // 正常完成的情况 + Observable.just("A", "B") + .doOnTerminate(() -> + System.out.println("即将完成/出错") + ) + .doOnComplete(() -> + System.out.println("doOnComplete") + ) + .subscribe( + item -> System.out.println("接收: " + item), + error -> { + }, + () -> System.out.println("onComplete") + ); + + // 输出: + // 接收: A + // 接收: B + // 即将完成/出错 + // doOnComplete + // onComplete + + // 出错的情况 + Observable.error(new RuntimeException("测试")) + .doOnTerminate(() -> + System.out.println("即将完成/出错") + ) + .doOnError(error -> + System.out.println("doOnError: " + error.getMessage()) + ) + .subscribe( + item -> { + }, + error -> System.out.println("onError: " + error.getMessage()) + ); + + // 输出: + // 即将完成/出错 + // doOnError: 测试 + // onError: 测试 + + } +``` + +### doAfterTerminate - 完成/错误后执行 + +```java + public static void main(String[] args) { + // 正常完成的情况 + Observable.just("A", "B") + .doAfterTerminate(() -> + System.out.println("完成/出错后执行") + ) + .doOnComplete(() -> + System.out.println("doOnComplete") + ) + .subscribe( + item -> System.out.println("接收: " + item), + error -> { + }, + () -> System.out.println("onComplete") + ); + + // 输出: + // 接收: A + // 接收: B + // doOnComplete + // onComplete + // 完成/出错后执行 + + // 出错的情况 + Observable.error(new RuntimeException("测试")) + .doAfterTerminate(() -> + System.out.println("完成/出错后执行") + ) + .doOnError(error -> + System.out.println("doOnError: " + error.getMessage()) + ) + .subscribe( + item -> { + }, + error -> System.out.println("onError: " + error.getMessage()) + ); + + // 输出: + // doOnError: 测试 + // onError: 测试 + // 完成/出错后执行 + } +``` + +### doOnDispose - 取消订阅时 + +```java + public static void main(String[] args) throws InterruptedException { + Disposable disposable = Observable.interval(1, TimeUnit.SECONDS) + .doOnDispose(() -> + System.out.println("订阅被取消了") + ) + .subscribe(num -> + System.out.println("计数: " + num) + ); + + Thread.sleep(3500); + disposable.dispose(); + + // 输出: + // 计数: 0 + // 计数: 1 + // 计数: 2 + // 订阅被取消了 + } +``` + +### doFinally - 最终执行 + +```java + public static void main(String[] args) { + Observable.just("数据") + .doFinally(() -> + System.out.println("无论如何都会执行") + ) + .subscribe(); + + // 输出: + // 无论如何都会执行 + } +``` + +### doOnEach - 每个事件时 + +```java + public static void main(String[] args) { + Observable.just("A", "B", "C") + .doOnEach(notification -> { + if (notification.isOnNext()) { + System.out.println("发射: " + notification.getValue()); + } else if (notification.isOnComplete()) { + System.out.println("完成"); + } else if (notification.isOnError()) { + System.out.println("错误: " + notification.getError()); + } + }) + .subscribe(); + + // 输出: + // 发射: A + // 发射: B + // 发射: C + // 完成 + } +``` + +### doOnRequest - 请求时(背压相关) + +```java + public static void main(String[] args) { + Flowable.range(1, 100) + .doOnRequest(requested -> + System.out.println("下游请求了 " + requested + " 个元素") + ) + .subscribe( + num -> System.out.println("接收: " + num), + error -> { + }, + () -> System.out.println("完成") + ); + + } +``` + +# 结束 + +响应式编程确实需要一些时间来掌握,但一旦理解核心概念,你就会发现它在处理异步数据流时的强大之处。 + +从回调地狱到流畅的链式调用,从手忙脚乱的资源管理到智能的背压控制,响应式编程让复杂异步操作变得清晰可控。虽然学习曲线存在,但投入是值得的。 + +记住,不是所有场景都需要响应式。在合适的业务场景下使用,才能发挥最大价值。建议从实际项目的小模块开始尝试,逐步积累经验。 + +希望这篇内容能为你打开响应式编程的大门。编程之路就是不断学习的过程,保持好奇,继续探索吧! From e28d1c06e4b7fab3c060d4b44aecd02c54224864 Mon Sep 17 00:00:00 2001 From: zhengshuhai <1242909896@qq.com> Date: Mon, 6 Apr 2026 10:39:01 +0800 Subject: [PATCH 09/12] update --- README.md | 7 +- ...15\350\242\253\351\227\256\345\200\222.md" | 291 ++++++++++++++++++ 2 files changed, 292 insertions(+), 6 deletions(-) create mode 100644 "docs/md/java/\351\235\242\350\257\225\345\256\230\357\274\232\345\223\215\345\272\224\345\274\217\347\274\226\347\250\213\345\222\214\350\231\232\346\213\237\347\272\277\347\250\213\346\200\216\344\271\210\351\200\211\357\274\237\347\234\213\345\256\214\350\277\231\347\257\207\344\270\215\345\206\215\350\242\253\351\227\256\345\200\222.md" diff --git a/README.md b/README.md index f08e788..201a561 100644 --- a/README.md +++ b/README.md @@ -32,18 +32,13 @@ ### :page_facing_up: JVM ### - [面试官:JVM是如何判定对象已死的?学JVM必会的知识!](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247486087&idx=1&sn=c6f1a9932961095ffdf2aef8a789e115&chksm=cf847942f8f3f0549c798671fe804c93378586b4fc547cce14db2359852ff0723a3aab64a187#rd) - - [GC的前置工作,聊聊GC是如何快速枚举根节点的](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247486168&idx=1&sn=9eef35ec701b5c2f8097641b7e69ae71&chksm=cf84791df8f3f00b1e85039f31b17e00bf9cb624bbee638efeca110e51df6c6b6ba6363705ee#rd) - - [GC面临的困境,JVM是如何解决跨代引用的?](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247486242&idx=1&sn=83d4ace26fea86b0f16e93e25b3cdadf&chksm=cf8478e7f8f3f1f17a65a7fc0d25237e8f25b90f300085bb5a7e8128f7d80f5ba1a02e5a6c2f#rd) - - [昨晚做梦面试官问我三色标记算法](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247486265&idx=1&sn=1464f25915c2c09ef65b784985b76fa3&chksm=cf8478fcf8f3f1ea80715ae949c1b4aec988368ead269c746d38244ae62028948a199f099d14#rd) - - [深入解析CMS垃圾回收器](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247486628&idx=1&sn=984b273af7d1d0398517a2f5442ffb38&chksm=cf847f61f8f3f677372a5ebc9f81403a8324be1bed49bf92e763882715c943324de4f1b0139a#rd) - - [深入解析G1垃圾回收器](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247486736&idx=1&sn=5e0710485783c3bcc4854a10412b9a40&chksm=cf847ed5f8f3f7c3826fa8c67bc76ce8dd218a725ee04f54cdafa27e14d190f5c92332589ae2#rd) - - [深入解析ZGC垃圾回收器](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247486746&idx=1&sn=7257ecf8c36509d06be359e3889400f2&chksm=cf847edff8f3f7c96edc667051d9ef70537000202c1ec77699fa5e30e46c2c8ddabd122297f3#rd) +- 面试官:响应式编程和虚拟线程怎么选?看完这篇不再被问倒 ### :hammer: MySQL ### diff --git "a/docs/md/java/\351\235\242\350\257\225\345\256\230\357\274\232\345\223\215\345\272\224\345\274\217\347\274\226\347\250\213\345\222\214\350\231\232\346\213\237\347\272\277\347\250\213\346\200\216\344\271\210\351\200\211\357\274\237\347\234\213\345\256\214\350\277\231\347\257\207\344\270\215\345\206\215\350\242\253\351\227\256\345\200\222.md" "b/docs/md/java/\351\235\242\350\257\225\345\256\230\357\274\232\345\223\215\345\272\224\345\274\217\347\274\226\347\250\213\345\222\214\350\231\232\346\213\237\347\272\277\347\250\213\346\200\216\344\271\210\351\200\211\357\274\237\347\234\213\345\256\214\350\277\231\347\257\207\344\270\215\345\206\215\350\242\253\351\227\256\345\200\222.md" new file mode 100644 index 0000000..b9fdea0 --- /dev/null +++ "b/docs/md/java/\351\235\242\350\257\225\345\256\230\357\274\232\345\223\215\345\272\224\345\274\217\347\274\226\347\250\213\345\222\214\350\231\232\346\213\237\347\272\277\347\250\213\346\200\216\344\271\210\351\200\211\357\274\237\347\234\213\345\256\214\350\277\231\347\257\207\344\270\215\345\206\215\350\242\253\351\227\256\345\200\222.md" @@ -0,0 +1,291 @@ +Java的高并发问题由来已久。传统线程模型下,每个Java线程映射一个操作系统内核线程,而操作系统线程是昂贵资源——默认每个线程消耗约1MB栈内存,调度还要在内核态与用户态之间来回切换。这让Java在处理高并发IO密集型应用时,总被Go、Lua等支持协程的语言压一头。为突破这个瓶颈,Java生态先后涌现出响应式编程与虚拟线程两种方案。前者要求改变编程范式,后者在底层机制上动刀,保留传统编码习惯。这两条路线的竞争,关系到Java平台的演进方向。 + +## 传统线程模型的瓶颈 + +先看传统thread-per-request模型有什么问题。以Tomcat为例,其维护的线程池默认最大线程数为200,单进程同时处理的最大并发请求数被这个数字死死卡住。当请求涉及数据库查询、缓存访问、下游服务调用等IO操作时,处理线程会在IO等待期间被阻塞,看起来线程很多,真正干活的可能没几个。 + +提升并发能力的传统方法是增加线程池大小,但会遇到三重限制: + +- **系统资源限制**:操作系统支持的内核线程数量有限,Java平台线程与内核线程1:1映射,扩展不了。实测4000个平台线程,总线程栈空间占用约8096MB。 +- **调度开销累积**:平台线程调度由内核调度器完成,线程多了,上下文切换就频繁,CPU资源消耗在调度上而不是业务处理上。 +- **IO阻塞的低效性**:线程在IO等待期间完全闲置,干不了别的事。典型企业应用里,线程大部分时间都在等——数据库查询、HTTP调用、文件读写,真正CPU干活的时间很短,大把时间耗在等待上。 + +响应式编程就是在这种背景下出来的,想通过编程范式的变革绕过硬件限制。 + +## 响应式编程:代价沉重的性能提升 + +响应式编程的核心思想是"缓冲区+回调",通过非阻塞IO让少量线程一直忙。技术实现依赖三块: + +- **非阻塞IO基础设施**:JDK 7引入的NIO为非阻塞操作打开了门,Socket读写、文件操作、锁API都有非阻塞版本。Spring WebFlux基于Project Reactor构建,用`Mono`和`Flux`类型实现发布-订阅模式,解耦数据生产者与消费者。 +- **事件循环模型**:单个线程通过事件循环处理多个请求,IO操作期间不阻塞线程,而是注册回调函数,数据就绪后由事件循环触发处理。 +- **背压机制**:通过流量控制防止生产者压垮消费者,这是响应式流规范的核心特性。 + +### 响应式代码的复杂性 + +响应式编程的性能优势明显,但代价也不小。看一个电商购物车价格计算的例子,传统代码: + +```java +public void addProductToCart(String productId, String cartId) { + Product product = repository.findById(productId) + .orElseThrow(() -> new IllegalArgumentException("not found!")); + + Price price = product.basePrice(); + if (product.category().isEligibleForDiscount()) { + BigDecimal discount = discountService.discountForProduct(productId); + price.setValue(price.getValue().subtract(discount)); + } + + var event = new ProductAddedToCartEvent(productId, price.getValue(), price.getCurrency(), cartId); + kafkaTemplate.send(PRODUCT_ADDED_TO_CART_TOPIC, cartId, event); +} +``` + +改造成响应式风格: + +```java +void addProductToCart(String productId, String cartId) { + repository.findById(productId) + .switchIfEmpty(Mono.error(() -> new IllegalArgumentException("not found!"))) + .flatMap(this::computePrice) + .map(price -> new ProductAddedToCartEvent(productId, price.value(), price.currency(), cartId)) + .subscribe(event -> kafkaTemplate.send(PRODUCT_ADDED_TO_CART_TOPIC, cartId, event)); +} + +Mono computePrice(Product product) { + if (product.category().isEligibleForDiscount()) { + return discountService.discountForProduct(product.id()) + .map(product.basePrice()::applyDiscount); + } + return Mono.just(product.basePrice()); +} +``` + +代码量增加不是最要命的。响应式编程真正的痛点在于: + +- **可读性崩溃**:回调嵌套形成"回调地狱",链式操作符(`flatMap`、`map`、`zip`)把业务逻辑碎片化,代码审查时很难快速理解执行流程。操作全封装成回调函数,回调里面再嵌回调,看着头疼。 +- **调试黑洞**:在回调函数里打断点,调用栈追溯不到业务入口。传统阻塞式编程通过栈帧能逐层定位调用方,响应式代码的调用链路被异步边界切断,异常堆栈常常变成一堆废话,给不出有效的定位信息。 +- **思维模式冲突**:大多数程序员习惯阻塞式思维,响应式编程要求从流处理、背压控制、异步编排的角度思考,认知成本高。 +- **生态兼容性割裂**:WebFlux要求全链路非阻塞,传统阻塞式API(JPA、JDBC、RestTemplate)没法直接用,得换成R2DBC、WebClient等响应式组件。遗留项目迁移成本巨大,而且响应式生态并不完备,有些场景得自己造轮子。 + +### 响应式编程的性能边界 + +响应式编程不是万能药,性能优势主要在IO密集型场景。对于计算密集型任务,响应式编程往往适得其反——线程在CPU密集计算期间释放不了,反而搭进去响应式框架的额外开销。 + +压测数据显示,WebFlux在IO密集型场景下,用25个线程就能达到964 req/sec的吞吐量,远超传统线程池的388 req/sec(200线程)或975 req/sec(500线程)。但这要付出代码复杂度和维护成本的巨大代价。 + +## 虚拟线程的技术实现 + +Java 21引入的虚拟线程(Virtual Thread),不改变编程范式,却实现了响应式编程的性能目标。核心技术原理: + +**virtual thread = continuation + scheduler + runnable** + +### 虚拟线程的工作机制 + +虚拟线程不与特定操作系统线程绑定,而是在平台线程(载体线程)上运行Java代码,但在代码整个生命周期内不独占平台线程。多个虚拟线程可以在同一个平台线程上运行,共享平台线程资源。 + +**Continuation组件**是虚拟线程的核心,它既包装用户的真实任务,又提供虚拟线程任务暂停/继续的能力,还负责虚拟线程与平台线程之间的数据转移: + +- 任务需要阻塞挂起时(如IO操作、锁等待、sleep),调用Continuation的yield操作,虚拟线程从平台线程卸载(unmount)。 +- 任务解除阻塞继续执行时,调用Continuation的run方法,虚拟线程重新挂载(mount)到载体线程。 + +具体实现细节: + +- **Mount操作**:虚拟线程挂载到平台线程,Continuation堆栈帧数据从堆内存拷贝到平台线程栈,是从堆到栈的复制过程。 +- **Unmount操作**:虚拟线程从平台线程卸载,Continuation栈数据帧留在堆内存中,载体线程被释放到调度器等待新任务。 +- **调度器设计**:JVM用FIFO模式的ForkJoinPool作为虚拟线程调度器,当平台线程对应的虚拟线程任务列表全部阻塞时,支持工作窃取(work-stealing),平台线程可以去窃取其他平台线程的虚拟线程执行。 + +### 虚拟线程的内存优势 + +虚拟线程的低成本让它可以大规模创建: + +**平台线程资源占用**: + +- 预留1MB线程栈空间, +- 平台线程实例占据2000+字节。 + +**虚拟线程资源占用**: + +- Continuation栈占用数百字节到数百KB,作为堆栈块对象存储在Java堆中。 +- 虚拟线程实例占据200-240字节。 + +实测数据:4000个平台线程总内存占用超过8000MB,而4000个虚拟线程内存占用不到300MB。而且虚拟线程的堆栈在堆中存储,可以被GC回收,进一步降低内存压力。 + +### 虚拟线程的自动卸载机制 + +虚拟线程的核心价值在于遇到阻塞操作时自动卸载,释放载体线程。JVM对核心类库做了改造,当代码遇到IO操作时,自动切换到非阻塞版本: + +```java +Thread.startVirtualThread(() -> { + // 阻塞调用,但不会阻塞载体线程 + Product product = repository.findById(productId); + BigDecimal discount = discountService.discountForProduct(productId); + // ...业务逻辑 +}); +``` + +虚拟线程执行到`repository.findById()`时,JVM检测到IO操作,触发Continuation.yield(),虚拟线程从载体线程卸载,载体线程转而去执行其他虚拟线程。等数据库返回数据后,虚拟线程重新挂载到载体线程(可能是另一个载体线程)继续执行。 + +这种机制让开发者用传统的阻塞式编程思维,就能享受到响应式编程的性能优势。 + +## 虚拟线程的局限 + +虚拟线程不是银弹,有它的局限: + +### Pinned Thread问题 + +虚拟线程执行以下操作时,无法进行yield操作,,载体线程会被阻塞: + +- **Native方法调用**:JNI调用或Foreign Function & Memory API无法卸载虚拟线程。 +- **synchronized代码块**:在synchronized修饰的方法或代码块中,虚拟线程会pin住载体线程。官方建议用ReentrantLock替代: + +```java +// 错误:会导致载体线程阻塞 +synchronized(lock) { + // IO操作 +} + +// 正确:虚拟线程可正常卸载 +ReentrantLock lock = new ReentrantLock(); +lock.lock(); +try { + // IO操作 +} finally { + lock.unlock(); +} +``` + +### ThreadLocal陷阱 + +虚拟线程支持ThreadLocal,但因为虚拟线程数量可能达到数百万,ThreadLocal中存储的线程变量会急剧增加,导致频繁GC影响性能。官方建议: + +- 尽量少用ThreadLocal。 +- 不要在虚拟线程的ThreadLocal中放大对象。 +- 使用ScopedLocal替代ThreadLocal。 + +### 池化思维的误区 + +虚拟线程占用资源极少,不需要池化。平台线程因为创建成本高需要池化共享,但虚拟线程应该"用时创建,用完即弃": + +```java +// 错误:虚拟线程不需要池化 +ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor(); +for(Task task : tasks) { + pool.submit(task); +} + +// 正确:直接创建虚拟线程 +for(Task task : tasks) { + Thread.startVirtualThread(task); +} +``` + +### 适用场景限定 + +虚拟线程只适用于IO密集型应用,计算密集型场景发挥不了优势。对于CPU密集计算,虚拟线程在执行期间无法卸载,反而引入调度开销。 + +## 技术选型决策 + +基于上述分析,虚拟线程与响应式编程的选型可以遵循以下原则: + +### 优先选择虚拟线程的场景 + +- **传统Web应用或REST API**:基于Spring MVC的应用,只需启用虚拟线程配置(`spring.threads.virtual.enabled=true`),就能获得显著的性能提升。 +- **遗留项目迁移**:虚拟线程与现有阻塞式API(JPA、JDBC、RestTemplate)完全兼容,迁移成本低。 +- **团队技术栈约束**:团队没有响应式编程经验,或者希望保持代码可读性和调试便利性。 +- **中高并发IO密集型场景**:包含大量数据库查询、HTTP调用、文件操作的应用。 + +### 选择响应式编程的场景 + +- **流数据处理**:实时数据流、事件流处理,WebFlux的背压机制可以防止生产者压垮消费者。 +- **长连接应用**:WebSocket、Server-Sent Events等需要维持大量长连接的场景,WebFlux的事件循环模型更高效。 +- **端到端非阻塞架构**:系统架构要求全链路非阻塞,从网关到服务到数据库都用响应式技术栈。 +- **全新项目且团队具备响应式经验**:启动全新项目,团队熟悉响应式编程,可以构建完全非阻塞的技术栈。 + +### 不应选择响应式编程的场景 + +- **计算密集型应用**:响应式编程无法提升CPU密集型任务性能,反而引入框架开销。 +- **遗留系统改造**:把现有Spring MVC应用改成WebFlux要重写大部分代码,风险不可控。 +- **团队响应式经验不足**:学习曲线陡,容易引入难以排查的并发问题,维护成本高。 + +## Spring Boot 3.2+的虚拟线程实践 + +Spring Boot 3.2提供了虚拟线程的原生支持,集成很简单: + +### 启用虚拟线程 + +```properties +# application.properties +spring.threads.virtual.enabled=true +``` + +这个配置会自动: +- Tomcat请求处理线程使用虚拟线程。 +- 异步任务执行器使用虚拟线程。 +- ScheduledExecutor使用虚拟线程。 + +### 手动创建虚拟线程 + +```java +// 方式1:Thread API +Thread vt = Thread.startVirtualThread(() -> { + // 业务逻辑 +}); + +// 方式2:ExecutorService +try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + executor.submit(() -> { + // 业务逻辑 + return result; + }); +} + +// 方式3:StructuredTaskScope(Java 21预览特性) +try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { + Future user = scope.fork(() -> findUser()); + Future order = scope.fork(() -> fetchOrder()); + + scope.join(); + scope.throwIfFailed(); + + return new Response(user.resultNow(), order.resultNow()); +} +``` + +### 与传统代码的兼容性 + +虚拟线程最大的优势是与现有阻塞式代码完全兼容: + +```java +@RestController +public class UserController { + @Autowired + private UserService userService; // 传统阻塞式Service + + @GetMapping("/users/{id}") + public User getUser(@PathVariable Long id) { + // 在虚拟线程上执行,阻塞不会阻塞载体线程 + return userService.findUserById(id); + } +} +``` + +不需要修改Service层代码,不用引入响应式类型,不用学新API,性能提升直接见效。 + +## 虚拟线程与响应式编程的本质 + +从技术本质看,虚拟线程与响应式编程追求的是同一目标:让少量平台线程一直忙,别在IO等待期间闲着。差异在实现层次: + +- **响应式编程**:在应用层通过编程范式变革实现,要求开发者显式构建异步管道,使用非阻塞API,思维模式要完全转换。 +- **虚拟线程**:在JVM层通过运行时机制实现,开发者不用改变编程习惯,JVM自动处理阻塞与恢复,底层实现continuation机制。 + +这就是虚拟线程能替代响应式编程的原因——用更低的学习成本、更少的代码改动、更好的可维护性,实现了相同的性能目标。响应式编程是个"中间产物",存在的价值是填补Java平台缺失轻量级线程的空白。当JVM原生支持虚拟线程后,响应式编程的复杂度成本就变得不可接受了。 + +当然,响应式编程不会马上消失。WebFlux在流处理、长连接等特定场景还有优势,而且大量现有系统已经采用响应式架构。但对于新项目,尤其是传统Web应用和微服务,虚拟线程是更务实的选择。Tomcat 11.0、Jetty 12.0都已经支持虚拟线程,主流框架的集成让虚拟线程的使用门槛降到很低。 + +## Java并发编程的未来 + +虚拟线程的引入,改变了Java并发编程的格局。它不是响应式编程的简单替代,而是Java平台对轻量级并发的原生支持。 + +响应式编程没有完全失去价值。在流处理、事件驱动架构、全链路非阻塞系统等领域,WebFlux还有其独特优势。但对于绝大多数企业应用,虚拟线程提供了性能与开发效率的最佳平衡点。 + +技术演进的逻辑是降低复杂度。响应式编程以增加复杂度换取性能,虚拟线程通过底层机制革新,在不增加应用层复杂度的前提下实现性能提升。两个方案性能相当,选择成本更低的那个是自然的技术演进方向。 \ No newline at end of file From 282adb2b0c02138cb9770f514c5f8de2c0a26db4 Mon Sep 17 00:00:00 2001 From: zhengshuhai <1242909896@qq.com> Date: Mon, 6 Apr 2026 15:38:05 +0800 Subject: [PATCH 10/12] update --- README.md | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/README.md b/README.md index 201a561..fc7cb61 100644 --- a/README.md +++ b/README.md @@ -14,20 +14,14 @@ ### :star: Java ### - [深入详解ThreadLocal](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247486776&idx=1&sn=f4425cb88bc5393e4d5125f5fd08ed68&chksm=cf847efdf8f3f7ebc79c5bcd3c47f1fc2f83abf119c2b22782cc90a1c69f606a95a4051dab53#rd) - - [使用Optional优雅避免空指针异常](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247486914&idx=1&sn=b2b0f2c41b8168fbfcf1df21a3e00acb&chksm=cf847e07f8f3f711de06cb9269ba41541ec9399a56963768add081031566bf7fa49cbb6f7fa0#rd) - - [我画了35张图就是为了让你深入 AQS](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247486172&idx=1&sn=b39cccd87dcd21176597dce0b15f7232&chksm=cf847919f8f3f00f86219d44cd95badee969d754aec89e644992437f2e8e0f7ad784695b4d90#rd) - - [一个 static 还能难得住我?](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247486175&idx=1&sn=041c85c052c11d2d15243994bc46d90a&chksm=cf84791af8f3f00c90a18b29d1fa47c9bcd713651514fc5ce4a9f82d656fe637bb21d45c42be#rd) - - [原来这才是动态代理!!!](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247486178&idx=1&sn=9610c1a0fa1df4c69558408ab2a3fcae&chksm=cf847927f8f3f0315b0c86f9b577926820c3d264d605149f850b597fcd17fafe432d82aaffcf#rd) - - [synchronized 的超多干货!](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247486181&idx=1&sn=4cb9340ba2f19ccb19ccec0c54d61b86&chksm=cf847920f8f3f036cd752455290a97f6584f8a4ce9662d1102515dd5ed967c94e14cec7a767d#rd) - - [ExecutorCompletionService详解](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247487958&idx=1&sn=2ace7ac53d596cd909d1d1c7e96fbff2&chksm=cf846213f8f3eb05c9de1fab2c609f4774ca86497ad5542a26aae5928efd808bfd865738aa4f#rd) - - [CompletableFuture深度解析](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247488046&idx=1&sn=2bb0b6dc4576278ff2e7f9b917cb6fe8&chksm=cf8461ebf8f3e8fd013d08c5028d41281444b1ac1d60f1706c841c4b444a4235d82c84644b9b#rd) +- [面试官:响应式编程和虚拟线程怎么选?看完这篇不再被问倒](https://mp.weixin.qq.com/s/V7H_hyjycT3n1Rr7FBOrYQ) ### :page_facing_up: JVM ### @@ -38,7 +32,6 @@ - [深入解析CMS垃圾回收器](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247486628&idx=1&sn=984b273af7d1d0398517a2f5442ffb38&chksm=cf847f61f8f3f677372a5ebc9f81403a8324be1bed49bf92e763882715c943324de4f1b0139a#rd) - [深入解析G1垃圾回收器](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247486736&idx=1&sn=5e0710485783c3bcc4854a10412b9a40&chksm=cf847ed5f8f3f7c3826fa8c67bc76ce8dd218a725ee04f54cdafa27e14d190f5c92332589ae2#rd) - [深入解析ZGC垃圾回收器](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247486746&idx=1&sn=7257ecf8c36509d06be359e3889400f2&chksm=cf847edff8f3f7c96edc667051d9ef70537000202c1ec77699fa5e30e46c2c8ddabd122297f3#rd) -- 面试官:响应式编程和虚拟线程怎么选?看完这篇不再被问倒 ### :hammer: MySQL ### From 005d5df8248b7bc0d60d0d5e060414d8fd1d98cc Mon Sep 17 00:00:00 2001 From: zhengshuhai <1242909896@qq.com> Date: Tue, 7 Apr 2026 21:39:03 +0800 Subject: [PATCH 11/12] update --- README.md | 11 +- ...00\346\234\257\346\217\255\347\247\230.md" | 196 ++++++++ ...00\346\234\257\351\235\251\345\221\275.md" | 428 ++++++++++++++++++ 3 files changed, 632 insertions(+), 3 deletions(-) create mode 100644 "docs/md/AI/\344\270\272\344\273\200\344\271\210ChatGPT\350\203\275\345\220\254\346\207\202\344\275\240\350\257\264\347\232\204\350\257\235\357\274\237Embedding\346\212\200\346\234\257\346\217\255\347\247\230.md" create mode 100644 "docs/md/AI/\346\267\261\345\272\246\350\247\243\346\236\220Skills\357\274\232\344\273\216Prompt\345\210\260\350\203\275\345\212\233\345\244\215\347\224\250\347\232\204\346\212\200\346\234\257\351\235\251\345\221\275.md" diff --git a/README.md b/README.md index fc7cb61..47b2e4d 100644 --- a/README.md +++ b/README.md @@ -161,13 +161,18 @@ ### :eyes: 大数据 ### - [Spark入门指南:从基础概念到实践应用全解析](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247487398&idx=1&sn=077859e1109e07b1469d242ec2b8091a&chksm=cf847c63f8f3f575e50012ef3667d9724998f07e32ebd27b6e3a37c5bdf2251d02e89030cff0#rd) - + - [HBase入门指南](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247487105&idx=1&sn=2ee82c9b239aa502bd3dffcf320b3f93&chksm=cf847d44f8f3f452e1b8ac83b9f62f380e349615b67da92343539d4014077c2ad9e787e256cc#rd) - + - [全网最详细4W字Flink全面解析与实践(上)](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247487459&idx=1&sn=a1826b2d592fff29b5e11a374468796a&chksm=cf847c26f8f3f53073cc24584264fa2752a26c98bbd31c86bcf519296789eff05d72904d27ac#rd) - + - [全网最详细4W字Flink全面解析与实践(下)](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247487535&idx=1&sn=736f1adda56cc550191f17e7111598b5&chksm=cf8463eaf8f3eafc38819e342705df1884683e03d5d39e9df876834ab0a84f61cc55923a5a03#rd) +### :watch:AI + +- [深度解析Skills:从Prompt到能力复用的技术革命](https://mp.weixin.qq.com/s/Se6_L1PbhlEUGaBSY8sZsQ) +- 为什么ChatGPT能听懂你说的话?Embedding技术揭秘 + ### :jack_o_lantern: 其他 ### diff --git "a/docs/md/AI/\344\270\272\344\273\200\344\271\210ChatGPT\350\203\275\345\220\254\346\207\202\344\275\240\350\257\264\347\232\204\350\257\235\357\274\237Embedding\346\212\200\346\234\257\346\217\255\347\247\230.md" "b/docs/md/AI/\344\270\272\344\273\200\344\271\210ChatGPT\350\203\275\345\220\254\346\207\202\344\275\240\350\257\264\347\232\204\350\257\235\357\274\237Embedding\346\212\200\346\234\257\346\217\255\347\247\230.md" new file mode 100644 index 0000000..f14955d --- /dev/null +++ "b/docs/md/AI/\344\270\272\344\273\200\344\271\210ChatGPT\350\203\275\345\220\254\346\207\202\344\275\240\350\257\264\347\232\204\350\257\235\357\274\237Embedding\346\212\200\346\234\257\346\217\255\347\247\230.md" @@ -0,0 +1,196 @@ +ChatGPT、Claude这些AI助手能理解我们说的话,还能给出像样的回答。做到这点,靠的是Embedding技术。 + +没有它,大语言模型根本没法处理文字输入。Embedding把人类语言变成数字,让机器能"读懂"。 + +## 什么是Embedding + +Embedding就是把词语、句子变成一串数字。听起来简单,但背后的想法很有意思。 + +我们说"北京"这个词时,脑子里会想到:城市、首都、政治中心、文化古都。这些概念连在一起,构成我们对"北京"的理解。Embedding做的,就是把这种理解映射到数学空间里。 + +每个词变成一个向量——一组数字。有意思的是,语义相近的词,向量也靠得近。"北京"和"上海"的向量距离很近,都是城市。但"北京"和"苹果"就离得远,语义完全不同。 + + + +这就让机器能通过距离来理解语义,不是简单匹配关键词。 + +## 怎么把词变成数字 + +举个通俗的例子。描述几个水果: + +- 苹果:红色的、中等大小、甜度适中、脆的、圆形的。 +- 香蕉:黄色的、细长的、很甜、软的、弯曲的。 +- 西瓜:绿色的、很大的、甜度中等、多汁的、圆形的。 + +用数字表示这些特征,比如给每个特征打分(0到1): + +- 苹果:[0.9(红色),0.5(大小),0.7(甜度),0.8(脆度),1.0(圆润)]。 +- 香蕉:[0.1(红色), 0.3(大小),0.9(甜度),0.2(脆度), 0.2(圆润)]。 +- 西瓜:[0.1(红色),0.95(大小),0.6(甜度),0.3(脆度),1.0(圆润)]。 + +真实的Embedding要复杂得多,通常是几百到几千维。但思路就是这样:用数字刻画特征。 + +模型怎么学会这些特征?它看上下文。就像通过朋友圈了解一个人,模型通过观察一个词周围经常出现什么词,理解这个词的含义。 + +比如"银行": +- "我去银行存钱"——周围是"存钱",指金融机构。 +- "他坐在银行边钓鱼"——周围是"钓鱼",指河岸。 + +模型通过阅读海量文本,学会根据上下文判断词义。语义相近的词,向量也接近。 + +## 怎么衡量向量的相似度 + +用余弦相似度。计算两个向量的夹角: + +- 夹角小,相似度接近1,语义相似。 +- 夹角大,相似度接近0,语义不相关。 + +余弦相似度关注向量的方向,不是长度。在自然语言里,概念的相似性更多体现在"方向"上。 + + + +## 向量和张量的关系 + +机器学习里有个更广的概念——张量(Tensor),就是N维数组: + +- 0维张量是标量(单个数字)。 +- 1维张量是向量(一维数组)。 +- 2维张量是矩阵(二维数组)。 +- 更高维张量表示更复杂的数据结构。 + +Embedding向量是一维张量。在大语言模型里,虽然每个词的Embedding是向量,但批量处理时会组织成矩阵或更高维张量。 + +## 技术演进 + +Embedding技术从简单到复杂,走了一段路。 + +### Word2Vec:早期的尝试 + +Word2Vec是早期代表,关注单个词的向量化。思路是:通过上下文学习词的语义。 + +模型观察大量文本,学习哪些词经常出现在相似的语境中。"猫"和"老虎"都会出现在"动物园"、"宠物"这些上下文里。通过统计学习,这些词在向量空间中位置靠近。 + +这揭示了一个特性:语义相似性可以通过上下文分布的相似性来捕捉。 + +但Word2Vec有局限:每个词只有固定向量,处理不了一词多义。"苹果"可以指水果,也可以指科技公司,Word2Vec把它们映射到同一个向量。 + +### 自注意力机制:突破 + +Transformer引入的自注意力机制(Self-Attention)是重大突破。模型生成某个词的向量时,能同时考虑句子中所有其他词。 + +两个优势: + +- **长距离依赖**:传统序列模型里,词与词的依赖关系随距离增加而减弱。自注意力机制能直接计算句子中任意两个词的关联强度,不管它们离多远。这帮助模型理解复杂的句法和语义。 +- **动态上下文表示**:Word2Vec给每个词分配固定向量,自注意力机制根据上下文生成不同向量。"我吃了一个苹果"和"苹果公司发布了新产品",两个"苹果"向量完全不同。 + +### BERT:双向理解 + +BERT(Bidirectional Encoder Representations from Transformers)实现了双向上下文理解。预训练时同时考虑一个词左右两侧的所有词。 + +以"很长"这个词组为例: +- "这条河很长"——指河流长度。 +- "他当了很长时间的厂长"——指时间持续。 + +BERT根据不同上下文为"很长"生成不同向量。Embedding技术从静态表示迈向动态、上下文感知的语义理解。 + + + +## Embedding在LLM里的作用 + +Embedding是大语言模型(LLM)运转的基石。 + +### LLM内部怎么工作 + +用户向ChatGPT输入问题时,系统内部经历几个步骤: + +- **第一步:Tokenization(分词)**:分词器把文本拆成token。一个token可能是一个词、一个字,也可能是一个词组。"请写一首关于秋天的诗"会被拆为["请"、"写"、"一首"、"关于"、"秋天"、"的"、"诗"]。 +- **第二步:Embedding Lookup(向量查询)**:每个token有唯一ID。LLM内部维护巨大Embedding矩阵,类似字典。模型看到token ID,在矩阵中查找对应向量。 +- **第三步:Position Encoding(位置编码)**。模型要知道每个token在句子中的位置。给每个token加上位置编码向量,保留顺序信息。 +- **第四步:向量处理与生成**:语义向量和位置编码结合,形成最终输入。这些向量经过Transformer多层网络计算,生成输出。 + +Embedding把人类可读的语言变成机器可计算的数字。没有这一步,推理、理解、生成都无从谈起。 + + + +### 理解和推理的数学基础 + +Embedding的重要性不只体现在输入阶段。在向量空间里,复杂语义操作通过数学运算实现: + +- 语义相似性:向量余弦相似度度量。 +- 语义关系:向量运算捕捉("国王" - "男人" + "女人" ≈ "女王")。 +- 语义组合:向量加权求和。 + +这些数学操作让LLM进行推理和生成,不是简单模式匹配。 + +## RAG框架里的Embedding + +除了在LLM内部,Embedding在实际应用中也很重要,特别是在RAG(Retrieval-Augmented Generation,检索增强生成)框架中。 + +### RAG是什么 + +RAG把大语言模型和可搜索的外部知识库结合。核心想法:让模型访问训练时没见过的新信息,提升回答准确性和时效性。 + +传统LLM应用里,模型知识来自训练数据。GPT-4的训练数据截止到2023年,之后发生的事它不知道。RAG通过连接外部知识库,让模型实时获取最新信息。 + +### Embedding在RAG里的作用 + +在RAG框架中,Embedding连接外部知识库与大语言模型。工作流程: + +- **知识库准备**:把外部文档(PDF、网页等)分割成文本块。用Embedding模型把每个文本块转换为向量,存储在向量数据库。文本内容变成可计算的数学表示。 +- **查询与检索**:用户提问时,系统把查询语句转换为查询向量。在向量数据库中通过余弦相似度计算,找出最相似的top-k个文本块。这是语义检索,不是关键词匹配。 +- **生成**:检索出的文本块和用户查询一起传给大语言模型。模型基于这些信息生成回答。 + + + +### 模型一致性原则 + +RAG里有个原则必须遵守:导入数据和查询时,必须用同一个Embedding模型。 + +不同模型把相同文本映射到不同向量空间。导入和查询用不同模型,就像用英语语法规则理解中文句子,匹配会失败。保持模型一致,检索才准确。 + +### Embedding质量影响RAG效果 + +Embedding模型性能直接决定RAG效果。高质量模型能准确捕捉文本语义,检索出最相关的信息。模型性能不佳会: + +- 检索不准确:返回的内容相关性不高。 +- 遗漏关键信息:没检索到有用的信息。 +- 引入噪音:检索出不相关内容,干扰模型判断。 + +选合适的Embedding模型,是RAG系统成功的关键。 + +## 向量数据库 + +Embedding技术广泛应用后,专门存储和检索高维向量的向量数据库出现了。这类数据库的核心能力是相似性搜索,根据向量距离查找最相似的向量。 + +### 两类向量数据库 + +- **专用向量数据库**:完全为向量检索构建,采用高级索引算法(HNSW、IVF)在海量数据中实现毫秒级查询。代表产品有Pinecone、Milvus、Weaviate。优势是检索快、性能优化好,适合大规模向量检索。 +- **集成向量检索功能的通用数据库**:传统关系型或文档型数据库,通过插件或内置功能支持向量检索。代表产品有Elasticsearch(dense_vector字段)、PostgreSQL(pgvector插件)、Redis。优势是同时处理结构化数据和向量数据,适合混合检索场景。 + + + +### Elasticsearch的语义检索 + +Elasticsearch通过dense_vector字段和kNN(最近邻)搜索功能,把Embedding转换、存储和检索封装在一起。用户可以直接把Elasticsearch作为RAG框架的向量存储: + +- 导入文档时,配置处理管道让Elasticsearch自动调用模型把文本转换为向量并存储。 +- 查询时,系统自动把查询转换为向量,执行相似性搜索。 + +这降低了技术门槛,开发者不用单独部署向量数据库,就能实现语义检索。 + +## Embedding的价值 + +Embedding技术把语言变成数学,让计算机能"理解"人类语言。 + +从技术演进看,Embedding从简单的词向量发展到上下文感知的动态表示。从Word2Vec到BERT,再到如今的大语言模型,每次技术突破都伴随着Embedding能力提升。 + +从应用看,Embedding在LLM内部把自然语言转化为数学表示。在RAG等应用中,Embedding实现从关键词匹配到语义检索,大幅提升信息检索准确性。 + +未来,Embedding还会承担更多: + +- 多模态融合:把文本、图像、音频映射到统一的向量空间,实现跨模态理解和生成。 +- 知识图谱构建:通过向量表示构建大规模知识网络,支持复杂推理和决策。 +- 个性化推荐:基于用户行为和偏好的向量表示,实现精准个性化服务。 +- 隐私保护计算:在向量空间进行加密计算,保护数据隐私同时实现智能分析。 + +理解Embedding的原理和应用,有助于更好地使用AI工具,也为探索AI技术未来提供视角。在AI时代,Embedding将继续连接人类智慧与机器能力。 \ No newline at end of file diff --git "a/docs/md/AI/\346\267\261\345\272\246\350\247\243\346\236\220Skills\357\274\232\344\273\216Prompt\345\210\260\350\203\275\345\212\233\345\244\215\347\224\250\347\232\204\346\212\200\346\234\257\351\235\251\345\221\275.md" "b/docs/md/AI/\346\267\261\345\272\246\350\247\243\346\236\220Skills\357\274\232\344\273\216Prompt\345\210\260\350\203\275\345\212\233\345\244\215\347\224\250\347\232\204\346\212\200\346\234\257\351\235\251\345\221\275.md" new file mode 100644 index 0000000..8ac1184 --- /dev/null +++ "b/docs/md/AI/\346\267\261\345\272\246\350\247\243\346\236\220Skills\357\274\232\344\273\216Prompt\345\210\260\350\203\275\345\212\233\345\244\215\347\224\250\347\232\204\346\212\200\346\234\257\351\235\251\345\221\275.md" @@ -0,0 +1,428 @@ +## 从Prompt到Skills的转变 + +2023年到2024年是"Prompt工程"的黄金时期。到了2025年底,AI圈开始频繁讨论一个新概念——**Skills(技能)**。 + +GitHub上Skills相关仓库获得上万star,各行各业的专业人士开始分享自己封装的Skills。Skills到底是什么?它为什么能引发如此关注? + +### Skills的本质:模块化能力包 + +**Agent Skills是模块化的能力包,包含指令、元数据和可选资源(脚本、模板),让AI Agent在需要时自动加载和使用**。 + +Skills就像AI助手的"工作手册库"。它不是每次对话都要重新输入的临时指令,而是一套可以长期保存、随时调用的能力模块。 + +### 从"带新人"到"给手册" + +要理解Skills,先看传统AI交互的问题。 + +想象你在公司带一个新人。他聪明、理解能力强,但不熟悉规矩。 + +- Prompt方式就像你每次都口头交代任务:"今天写一段公众号开头"、"把这个语气改得更克制"、"按我的结构写一页PPT"。这适合一次性指令,但一旦关闭对话,所有指令就消失,下次得从头教。 +- Rules或记忆机制相当于在工位贴一张"公司行为守则",只能管态度和格式这类宽泛要求。 +- MCP和工具调用更像是给他的电脑装一堆软件和API,他能调用外部工具,但不知道什么时候该用、怎么组合。 + +**Skills**改变了这一局面。它就像给新人一本完整的公司内部SOP手册——不是长到让人窒息的Word文档,而是一个知识库文件夹,里面有规范、脚本、模板、参考资料。AI会在需要时自己翻阅,按需加载。 + +### Skills的物理形态 + +很多人问:"这不就是Prompt吗?"实际上两者在形态上有本质区别: + +- **Prompt**:一段文本(通常是Markdown格式)。 +- **Skills**:一个文件夹结构,包含多种资源。 + +一个标准的Skill目录: + +``` +skill-name/ +├── SKILL.md # 核心指令文件(必需) +├── scripts/ # 可执行脚本(可选) +├── references/ # 参考文档(可选) +├── templates/ # 模板文件(可选) +└── assets/ # 其他资源(可选) +``` + +![Skills标准目录结构](https://mmbiz.qpic.cn/mmbiz_jpg/55IJsnyicJud8uodFl0KLYh73KXlAj4vTFdRKbvQ5Ytow677A2VdzOTwFTSkAoOxG8TSw2T6AVAiabPSQTtUNRjTbNp2zHU5f7op2QnOlia3P8/640?wx_fmt=jpeg&from=appmsg) + +**SKILL.md是唯一必需的文件**,它采用YAML前导格式(类似简历开头的个人信息区),包含元数据和详细指令。这种设计让Skills不仅能承载知识,还能承载工具和流程。 + +## 渐进式披露架构 + +### 为什么"一次性塞进所有信息"行不通? + +Skills采用了**渐进式披露(Progressive Disclosure)**架构。这个概念在移动互联网时代曾是用户体验设计的核心原则之一。 + +打开一个APP,如果它一次性把所有功能、设置、选项都堆在你面前,你会怎样?认知负荷爆炸,不知所措。 + +人的瞬时记忆区非常有限,一瞬间只能接受最多7±2个信息块。AI也是如此——受限于Token窗口,对话越长,模型越"笨"。Token在Agent架构上寸土寸金。 + +传统做法:每次对话都把完整指令塞进上下文。一个详细的PDF处理工作流可能需要3000+ tokens。如果同时处理Excel、写代码、生成报告,上下文窗口很快爆满。 + +### 三层加载机制 + +Skills通过三层渐进式加载解决这个问题: + +**第一层:元数据——目录索引** + +这是Skills的"封面",包含技能名称和一句话描述。 + +- **加载时机**:每次对话开始时。 +- **Token消耗**:约100 tokens/Skill。 +- **作用**:让AI知道有哪些Skills可用,何时该用。 + +你可以安装数十个Skills,几乎没有性能损失。AI就像看图书馆的目录,知道有哪些书,但不必都翻开。 + +**第二层:指令——详细手册** + +当AI通过元数据判断某个任务需要特定Skill时,它会读取完整的SKILL.md文件。 + +- **加载时机**:任务匹配时触发。 +- **Token消耗**:数千tokens(按实际文件大小)。 +- **作用**:提供详细的操作指南和最佳实践。 + +用户说"帮我处理这个PDF",AI会判断匹配PDF Skill,然后加载详细的处理流程:先提取文本,再识别表单字段,最后填写并保存。 + +**第三层:资源和代码——深度参考** + +这层包括参考文档、可执行脚本、模板文件等。 + +- **加载时机**:SKILL.md中引用时。 +- **Token消耗**:按需加载。 +- **关键优势**:脚本执行不消耗上下文(仅结果消耗)。 + +一个包含复杂Python脚本的Skill,脚本本身的代码不会进入上下文,只有执行结果会返回。这让Skills可以承载几乎无限的资源,而不必担心Token限制。 + +![Skills三层渐进式加载架构](https://mmbiz.qpic.cn/mmbiz_jpg/55IJsnyicJudaM0BlQicyFr2GkWb0h6PY453f0zib4zGXeYmmFRxOTUbuJYzvJtBWJvoib61uqicYo3NkaAFgXSiaCcXu8vkAZpDAWWnbcuGPwj7M/640?wx_fmt=jpeg&from=appmsg) + +### 一个真实的加载流程 + +以PDF处理为例,看Skills如何工作: + +**阶段1:初始状态** + +``` +用户输入:"用PDF技能填写这份合同" +系统提示 + 技能目录 + 用户消息 +Token消耗:约100 tokens +``` + +**阶段2:加载主手册** + +``` +AI判断:这个任务匹配PDF Skill +执行:bash cat ~/.claude/skills/pdf/SKILL.md +Token消耗:+3000 tokens +``` + +**阶段3:按需加载参考资料** + +``` +AI判断:需要表单填写规则 +执行:bash cat ~/.claude/skills/pdf/references/forms.md +Token消耗:+500 tokens +``` + +**阶段4:执行脚本** + +``` +执行:python scripts/fill_form.py --input contract.pdf --output filled.pdf +Token消耗:+200 tokens(仅输出结果) +``` + +**总Token消耗**:约3800 tokens。 + +对比传统方式:一次性加载所有相关文档和脚本定义,可能需要10,000+ tokens。Skills节省了60-70%的上下文空间。 + +![Skills加载流程示意图](https://mmbiz.qpic.cn/mmbiz_jpg/55IJsnyicJucklf0se97hDpQvLOBs6icfRhVBSUxsVugsfufulhlveiaQyjWxCwmKgyjcoibtHUfy3tbFopcC6OvJ4f4dTUdHfz7rCDYtGNfq34/640?wx_fmt=jpeg&from=appmsg) + +## Skills vs MCP vs Prompt:互补关系 + +### 三者的核心定位 + +Skills、MCP、Prompt不是竞争关系,而是互补关系: + +| 维度 | Skills | MCP | Prompt | +|------|--------|-----|--------| +| **核心定位** | 工作流程指南(How) | 外部系统连接(What) | 临时指令 | +| **解决问题** | 如何使用能力 | 提供什么数据/能力 | 当下做什么 | +| **形象比喻** | 使用说明书 | 工具箱 | 口头指令 | +| **Token效率** | 高(渐进加载) | 低(全量加载) | 中(每次重复) | +| **复用性** | 强(文件系统) | 中(协议层面) | 弱(手动复制) | + +![Skills vs MCP vs Prompt对比图](https://mmbiz.qpic.cn/sz_mmbiz_jpg/55IJsnyicJueR41mACITrYuXs21Yfnk2T0gfSBdbtucm8H84mWbyyQafmjxujfPKdmtyUpkZKTRH49erkbeYEJsdStMKIwTea65LXsb3V91o/640?wx_fmt=jpeg&from=appmsg) + +### Skills与MCP:工作手册 vs 门禁卡 + +**Skills解决"怎么做"(方法论/工作流),MCP解决"连到哪儿"(连接外部系统)**。 + +用职场类比: +- **MCP**:给AI一张门禁卡,让它能进入公司的各个系统(数据库、API、外部工具)。 +- **Skills**:给AI一本工作手册,教它如何使用这些系统完成具体任务。 + +一个组合场景: + +**生成销售报告** + +1. **MCP提供数据连接** + - 连接Salesforce获取客户数据。 + - 连接PostgreSQL查询销售记录。 + - 连接Google Sheets读取目标数据。 + +2. **Skills提供工作流程** + - 数据提取顺序(先查哪个系统)。 + - 计算逻辑(增长率、完成率)。 + - 报告格式和模板。 + - 异常处理规则。 + +MCP解决"能访问什么数据",Skills解决"如何使用这些数据生成报告"。 + +![Skills与MCP协作关系](https://mmbiz.qpic.cn/sz_mmbiz_jpg/55IJsnyicJufs3KcaQqmXZY32Yic0vqM9FeQ2ibTiaFbDYB0qnqPpRMrDdbaMeZqVuUnWQ6v0ox1zJPkZcPeicIxIaECPpJjbo1HzyichwiaTOXhjQ/640?wx_fmt=jpeg&from=appmsg) + +### Skills vs Prompt:从临时指令到持久能力 + +**Skills不就是高级一点的Prompt吗?** + +答案既是肯定的,也是否定的。 + +**相同点**:Skills的核心确实是自然语言指令,这与Prompt一致。 + +**根本区别**: + +- **生命周期**:Prompt是对话级的,Skills是系统级的。 +- **复用方式**:Prompt需要手动复制粘贴,Skills自动匹配触发。 +- **承载能力**:Prompt只能承载文本,Skills可以承载脚本、模板、参考文档。 +- **Token效率**:Prompt每次都全量加载,Skills按需渐进加载。 + +用一个实际例子: + +**没有Skills时**,每次都要说: + +``` +帮我总结这篇文章 → 翻译成英文 → 改成公众号风格 → 加标题 → 输出Markdown格式 +``` + +**有了Skills后**,只需要一句: +``` +使用「技术文章转公众号」Skill +``` + +AI会自动按照预设的完整流程执行。 + +## 实际应用 + +### 个人场景:把重复工作封装成能力包 + +**案例1:AI选题系统** + +一个内容团队用Skills构建了自动化选题系统,包含: +- 1个总控Agent。 +- 3个Skill(热点采集、选题生成、选题审核)。 + +每天只需要一句:"开始今日选题生成",系统就会自动: +1. 从多个平台采集全网热点。 +2. 筛选并生成TOP10选题(包含事件描述、核心角度、标题)。 +3. 按照内部方法论自动审核。 +4. 不通过时给出修改意见并迭代优化。 + +过去需要2-3小时的工作,现在几分钟就能完成初筛。 + +**案例2:整合包生成器** + +很多GitHub开源项目没有前端界面,环境配置复杂。有人用Skills做了一个"整合包生成器": + +提供一个GitHub链接,Skill就会: +1. 分析项目结构。 +2. 自动生成前端界面。 +3. 编写启动脚本。 +4. 打包成开箱即用的整合包。 + +解决了"想用但不会配置"的痛点。 + +### 团队场景:知识资产沉淀与共享 + +**传统方式的问题**: +- 每个团队各自维护长Prompt。 +- 写法、风格不统一。 +- 复用靠复制粘贴。 +- 难以版本管理和评审。 + +**Skills带来的改变**: +- 把"怎么做好一件事"固化成SKILL.md + 脚本 + 参考文档。 +- 放入Git版本库,走标准开发流程。 +- 团队间共享、评审、复用。 +- 形成企业内部的"技能库"(Skill Library)。 + +**组织架构示例**: +``` +公司级Agent产品 +├── 市场部维护:品牌文案Skill +├── 法务部维护:合同审阅Skill +├── 财务部维护:报销审核Skill +└── 技术部维护:代码审查Skill +``` + +所有技能装在同一个Agent身上,用户只跟一个界面打交道。 + +### 行业场景:专业知识标准化 + +**医疗诊断流程**:将诊断标准、注意事项、药物禁忌等封装成Skill,确保AI遵循医疗规范 + +**法律文书审查**:将审查要点、风险识别、合规要求标准化,提高审查质量和一致性 + +**代码审计规范**:将安全检查项、代码风格要求、最佳实践固化 + +**ML实验配置**:将实验设计规范、参数推荐范围、结果记录模板封装 + +这些领域知识需要结构化存储、团队共享、版本管理、跨平台使用——正是Skills的强项。 + +## 技术实现 + +### 最小可行Skill + +创建一个Skill只需要一个SKILL.md文件: + +```markdown +--- +name: hello-skill +description: A simple skill that greets users +--- + +# Hello Skill + +When user says hello, respond with a friendly greeting. +``` + +**必填字段**: +- `name`:技能名称(小写字母、数字、连字符符)。 +- `description`:功能描述。 + +**简单到人人可创建,强大到专业团队可用**。 + +### 完整Skill:PDF处理案例 + +``` +pdf-skill/ +├── SKILL.md +├── scripts/ +│ ├── extract_text.py +│ ├── fill_form.py +│ └── merge_pdfs.py +├── references/ +│ ├── FORMS.md +│ └── API_REFERENCE.md +└── templates/ + └── report_template.md +``` + +**SKILL.md内容**: + +```markdown +--- +name: pdf-processing +description: Extract text and tables from PDF files, fill forms, merge documents. + Use when working with PDF files or when the user mentions PDFs. +--- + +# PDF Processing + +## Quick Start + +1. For text extraction, use `python {baseDir}/scripts/extract_text.py` +2. For form filling, see [FORMS.md](references/FORMS.md) +3. For merging PDFs, execute the merge script + +## Supported Operations + +- Text extraction from text-based PDFs +- OCR for scanned PDFs (requires Tesseract) +- Form field identification and filling +- Multi-document merging + +## Best Practices + +- Always validate PDF integrity before processing +- Use OCR only when necessary (higher token cost) +- Keep extracted text under 10,000 tokens for best performance +``` + +**关键点**: + +- `{baseDir}`是自动替换变量,表示Skill的安装路径。 +- 可以引用其他文件(如FORMS.md),AI会在需要时加载。 +- 指令清晰、结构化,便于AI理解和执行。 + +### 安装和使用 + +**方法1:命令安装** + +```bash +# 安装官方Skill +claude skill install https://github.com/anthropics/skills/tree/main/skills/pdf + +# 或在对话中直接说 +"安装这个skill:https://github.com/xxx/skill-name" +``` + +**方法2:手动放置** + +将Skill文件夹放到对应目录: +- Claude Code:`~/.claude/skills/`。 +- Cursor:`~/.cursor/skills/。` +- OpenCode:`~/.config/opencode/skill/`。 + +**使用方式**: + +直接对话: +``` +用户:"帮我处理这个PDF" +AI会自动识别并调用PDF Skill +``` + +或者显式指定: +``` +用户:"使用PDF Skill提取这份文档的文本" +``` + +## 未来展望 + +### 从工具到生态 + +目前Skills还处于早期阶段,但已经有了生态雏形: + +- **官方Skills库**:Anthropic开源了官方Skills仓库,包含PDF、Excel、PPT、Word等常用技能。 +- **社区贡献**:GitHub上涌现大量社区贡献的Skills,涵盖数据分析、代码审查、文档生成等多个领域。 +- **工具支持**:Claude Code、Cursor、OpenCode等主流工具均已支持Skills。 +- **技能市场**:扣子等平台开始提供技能市场,支持搜索、安装、分享Skills。 + +### 潜在挑战 + +Skills也面临挑战: + +- **标准化问题**:不同平台、不同团队的Skills格式可能不统一,需要建立行业标准。 +- **安全与隐私**:Skills可以执行脚本,需要沙箱隔离和权限控制。 +- **质量参差**:开放的生态意味着质量良莠不齐,需要评价和筛选机制。 +- **学习曲线**:虽然创建简单,但要设计高质量的Skill仍需要经验。 + +### 对AI发展的意义 + +Skills代表一个重要趋势:**从让AI"理解"到让AI"执行"**。 + +过去几年,我们主要关注如何让AI更好地理解自然语言、理解上下文、理解意图。这是必要的基础,但还不够。 + +Skills的出现,标志着我们开始关注如何让AI系统地、可重复地、高质量地执行复杂任务。这不仅需要理解能力,还需要方法论、最佳实践、工具链的支持。 + +**这是AI从"对话伙伴"进化为"工作伙伴"的关键一步。** + +## 今天就开始你的第一个Skill + +Skills的热度已不亚于当年的Prompts。但这不只是流行趋势,而是实实在在的生产力革命。 + +如果你还在犹豫是否要尝试Skills,建议从最简单的开始: + +**今天**,安装一个官方Skill(比如skill-creator),感受一下"一个命令安装能力"的便捷。 + +**明天**,把最常用的一个动作固化成Skill——比如选题筛热点、报错日志分析、链接摘要生成。 + +**后天**,你会想把更多工作流程都搬进去。 + +到那一步,你就进入了另一个状态:**自由,创造的状态**。 + +Skills的核心价值,在于**复用**。当你把一次性的努力转化为可重复调用的能力,你就不再是每次都从零开始,而是站在前人的肩膀上持续前进。 From f22b5e471fb0e0c637a61bf1dd29bb0bee49d609 Mon Sep 17 00:00:00 2001 From: zhengshuhai <1242909896@qq.com> Date: Wed, 8 Apr 2026 08:53:16 +0800 Subject: [PATCH 12/12] update --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 47b2e4d..6cccd56 100644 --- a/README.md +++ b/README.md @@ -171,7 +171,7 @@ ### :watch:AI - [深度解析Skills:从Prompt到能力复用的技术革命](https://mp.weixin.qq.com/s/Se6_L1PbhlEUGaBSY8sZsQ) -- 为什么ChatGPT能听懂你说的话?Embedding技术揭秘 +- [为什么ChatGPT能听懂你说的话?Embedding技术揭秘](https://mp.weixin.qq.com/s/CoHcpXIaamdfmXCf-3qlgw) ### :jack_o_lantern: 其他 ###