[{"content":"Docker 容器化部署基础\r基本\r镜像不仅包含应用本身, 还包括运行应用的环境,配置, 系统函数库等 容器是隔离的 镜像名字完整是 mysql:5.7 常见命令\rdocker inspect 容器名 日志\rdocker logs -f hmall 自动重启\rdocker update --restart=no nacos demo\rdocker pull nginx docker images docker save --help docker save -o nginx.tar nginx:latest docker rmi nginx:latest 从tar 包加载镜像 docker load -i nginx.tar docker run -d --name nginx -p 80:80 nginx:latest docker exec -it nginx bash 别名\rvi ~/.bashrc\ralias rm=\u0026#39;rm -i\u0026#39;\ralias cp=\u0026#39;cp -i\u0026#39;\ralias mv=\u0026#39;mv -i\u0026#39;\ralias dps=\u0026#39;docker ps --format \u0026#34;table {{.ID}}\\t{{.Image}}\\t{{.Ports}}\\t{{.Status}}\\t{{.Names}}\u0026#34;\u0026#39;\rsource ~/.bashrc 数据卷挂载\rdocker run -d --name nginx -p 80:80 -v html:/usr/share/nginx/html nginx docker volume ls docker volume inspect html [ { \u0026#34;CreatedAt\u0026#34;: \u0026#34;2025-01-30T22:01:25-08:00\u0026#34;, \u0026#34;Driver\u0026#34;: \u0026#34;local\u0026#34;, \u0026#34;Labels\u0026#34;: null, \u0026#34;Mountpoint\u0026#34;: \u0026#34;/var/lib/docker/volumes/html/_data\u0026#34;, \u0026#34;Name\u0026#34;: \u0026#34;html\u0026#34;, \u0026#34;Options\u0026#34;: null, \u0026#34;Scope\u0026#34;: \u0026#34;local\u0026#34; } ] 数据卷名字要唯一 /var/lib/docker/volumes/ 下会有很多数据卷, 自动创建 本地挂载\rdocker run -d --name mysqlCustom -p 3307:3306 -e MYSQL_ROOT_PASSWORD=1234 -v /root/mysql/data:/var/lib/mysql -v /root/mysql/init:/docker-entrypoint-initdb.d -v /root/mysql/conf:/etc/mysql/conf.d mysql D:\\BaiduNetdiskDownload\\java\\javaee\\SpringCloud微服务—资料\\day02-Docker\\资料\\mysql 注意删除重建时要检查 data 文件夹是否为空, finalshell 中 mysql.sock 不显示 自定义镜像\rdockerFile\r网络\r自定义网络\rdemo\rdocker network create heima ifconfig br-9852d5095d79: flags=4099\u0026lt;UP,BROADCAST,MULTICAST\u0026gt; mtu 1500 inet 172.18.0.1 netmask 255.255.0.0 broadcast 172.18.255.255 ether 02:42:8c:f8:42:b4 txqueuelen 0 (Ethernet) RX packets 0 bytes 0 (0.0 B) RX errors 0 dropped 0 overruns 0 frame 0 TX packets 0 bytes 0 (0.0 B) TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0 docker0: flags=4163\u0026lt;UP,BROADCAST,RUNNING,MULTICAST\u0026gt; mtu 1500 inet 172.17.0.1 netmask 255.255.0.0 broadcast 172.17.255.255 inet6 fe80::42:44ff:fe6f:e190 prefixlen 64 scopeid 0x20\u0026lt;link\u0026gt; ether 02:42:44:6f:e1:90 txqueuelen 0 (Ethernet) RX packets 73 bytes 7406 (7.2 KiB) RX errors 0 dropped 0 overruns 0 frame 0 TX packets 119 bytes 12184 (11.8 KiB) TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0 多了 br-9852d5095d79 网段是 172.18.0.1 docker network connect heima mysqlCustom mysqlCustom 的配置中多了一个网段如下 \u0026#34;Networks\u0026#34;: {\r\u0026#34;bridge\u0026#34;: {\r\u0026#34;IPAMConfig\u0026#34;: null,\r\u0026#34;Links\u0026#34;: null,\r\u0026#34;Aliases\u0026#34;: null,\r\u0026#34;MacAddress\u0026#34;: \u0026#34;02:42:ac:11:00:03\u0026#34;,\r\u0026#34;NetworkID\u0026#34;: \u0026#34;d0bd5b3ef25b7538f53890a554c4a8efeb1ee298e7ae887d8ce0a6c900bb3161\u0026#34;,\r\u0026#34;EndpointID\u0026#34;: \u0026#34;c8700ebba3bab506e40889f8378a230e36b8db79fccb73af94b7364b55109001\u0026#34;,\r\u0026#34;Gateway\u0026#34;: \u0026#34;172.17.0.1\u0026#34;,\r\u0026#34;IPAddress\u0026#34;: \u0026#34;172.17.0.3\u0026#34;,\r\u0026#34;IPPrefixLen\u0026#34;: 16,\r\u0026#34;IPv6Gateway\u0026#34;: \u0026#34;\u0026#34;,\r\u0026#34;GlobalIPv6Address\u0026#34;: \u0026#34;\u0026#34;,\r\u0026#34;GlobalIPv6PrefixLen\u0026#34;: 0,\r\u0026#34;DriverOpts\u0026#34;: null,\r\u0026#34;DNSNames\u0026#34;: null\r},\r\u0026#34;heima\u0026#34;: {\r\u0026#34;IPAMConfig\u0026#34;: {},\r\u0026#34;Links\u0026#34;: null,\r\u0026#34;Aliases\u0026#34;: [],\r\u0026#34;MacAddress\u0026#34;: \u0026#34;02:42:ac:12:00:02\u0026#34;,\r\u0026#34;NetworkID\u0026#34;: \u0026#34;9852d5095d79a3a43d64a4eb8d28e31a478a9f695879c20b6ad70deef1195e96\u0026#34;,\r\u0026#34;EndpointID\u0026#34;: \u0026#34;a919eac1641bdab467878c4c364550c8a1baa7eeec0154aff3f0bf94fd0fb66c\u0026#34;,\r\u0026#34;Gateway\u0026#34;: \u0026#34;172.18.0.1\u0026#34;,\r\u0026#34;IPAddress\u0026#34;: \u0026#34;172.18.0.2\u0026#34;,\r\u0026#34;IPPrefixLen\u0026#34;: 16,\r\u0026#34;IPv6Gateway\u0026#34;: \u0026#34;\u0026#34;,\r\u0026#34;GlobalIPv6Address\u0026#34;: \u0026#34;\u0026#34;,\r\u0026#34;GlobalIPv6PrefixLen\u0026#34;: 0,\r\u0026#34;DriverOpts\u0026#34;: {},\r\u0026#34;DNSNames\u0026#34;: [\r\u0026#34;mysqlCustom\u0026#34;,\r\u0026#34;d2fd3dcd672f\u0026#34;\r]\r}\r} 创建时加入\rdocker run -d --name dd -p 8080:8080 --network heima dockerdemo network 中就没有 docker0 了 \u0026#34;Networks\u0026#34;: {\r\u0026#34;heima\u0026#34;: {\r\u0026#34;IPAMConfig\u0026#34;: null,\r\u0026#34;Links\u0026#34;: null,\r\u0026#34;Aliases\u0026#34;: null,\r\u0026#34;MacAddress\u0026#34;: \u0026#34;02:42:ac:12:00:03\u0026#34;,\r\u0026#34;NetworkID\u0026#34;: \u0026#34;9852d5095d79a3a43d64a4eb8d28e31a478a9f695879c20b6ad70deef1195e96\u0026#34;,\r\u0026#34;EndpointID\u0026#34;: \u0026#34;05abbdf3791796d8380bf30f8d7f228eb3092db2a6d7fd0a1f6641cf1a70d91d\u0026#34;,\r\u0026#34;Gateway\u0026#34;: \u0026#34;172.18.0.1\u0026#34;,\r\u0026#34;IPAddress\u0026#34;: \u0026#34;172.18.0.3\u0026#34;,\r\u0026#34;IPPrefixLen\u0026#34;: 16,\r\u0026#34;IPv6Gateway\u0026#34;: \u0026#34;\u0026#34;,\r\u0026#34;GlobalIPv6Address\u0026#34;: \u0026#34;\u0026#34;,\r\u0026#34;GlobalIPv6PrefixLen\u0026#34;: 0,\r\u0026#34;DriverOpts\u0026#34;: null,\r\u0026#34;DNSNames\u0026#34;: [\r\u0026#34;dd\u0026#34;,\r\u0026#34;9897540b887f\u0026#34;\r]\r}\r} 部署demo\r部署后端\rdocker build -t hmall . docker run -d --name mysql -p 3307:3306 -e MYSQL_ROOT_PASSWORD=123 -v /root/mysql/data:/var/lib/mysql -v /root/mysql/init:/docker-entrypoint-initdb.d -v /root/mysql/conf:/etc/mysql/conf.d mysql docker network connect heima mysql docker run -d --name hmall -p 8080:8080 --network heima hmall http://192.168.87.129:8080/search/list/?pageNo=1\u0026amp;pageSize=5 部署前端\r下载 nginx 镜像 传输前端文件 运行 docker run -d --name nginx -p 18080:18080 -p 18081:18081 -v /root/qianduan/html:/usr/share/nginx/html -v /root/qianduan/nginx.conf:/etc/nginx/nginx.conf --network heima nginx 前端中设置的域名是 hmall 那么后端容器名字应该也是 hmall dockerCompose\r传统部署形式繁琐 不能体现出整体性, 维护困难 dockerCompose 可以管理一组相关联的容器, 实现多个相关联的容器快速部署\ndocker compose -f docker-compose.yml up -d demo\rversion: \u0026#34;3.8\u0026#34; services: mysql: image: mysql container_name: mysql ports: - \u0026#34;3306:3306\u0026#34; environment: TZ: Asia/Shanghai MYSQL_ROOT_PASSWORD: 123 volumes: - \u0026#34;./mysql/conf:/etc/mysql/conf.d\u0026#34; - \u0026#34;./mysql/data:/var/lib/mysql\u0026#34; - \u0026#34;./mysql/init:/docker-entrypoint-initdb.d\u0026#34; networks: - hm-net hmall: build: context: . dockerfile: Dockerfile container_name: hmall ports: - \u0026#34;8080:8080\u0026#34; networks: - hm-net depends_on: - mysql nginx: image: nginx container_name: nginx ports: - \u0026#34;18080:18080\u0026#34; - \u0026#34;18081:18081\u0026#34; volumes: - \u0026#34;./nginx/nginx.conf:/etc/nginx/nginx.conf\u0026#34; - \u0026#34;./nginx/html:/usr/share/nginx/html\u0026#34; depends_on: - hmall networks: - hm-net networks: hm-net: name: hmall redis\rdocker pull redis docker run --name my-redis -d redis docker run --name my-redis -p 6379:6379 -d redis docker run --name my-redis -v /my/redis/data:/data -d redis redis-server --appendonly yes docker run --name my-redis \\ -p 6379:6379 \\ -v /my/redis/data:/data \\ -d redis redis-server --appendonly yes --requirepass \u0026#34;yourpassword\u0026#34; docker exec -it my-redis redis-cli docker stop my-redis docker rm my-redis docker start my-redis ","date":"2026-04-11T00:00:00Z","permalink":"/p/docker-%E5%AE%B9%E5%99%A8%E5%8C%96%E9%83%A8%E7%BD%B2%E5%9F%BA%E7%A1%80/","title":"Docker 容器化部署基础"},{"content":"Git 常用命令\r这份笔记记录了我在日常使用 Git 的过程中最常用的几组命令，按操作频率整理成初始化、远程、忽略、提交、推送以及别名设置几个部分。所有命令都保持原样，配图也没有改动。\n初始化仓库\r在一个新目录下运行以下命令即可初始化本地仓库：\ngit init 初始化后可以观察 .git 目录下的 config 文件，里面记录了仓库的基本配置和远程信息。\n克隆远程仓库\r把远程项目拉下来，最常用的就是：\ngit clone https://adfsasdf.git 克隆完成后就会在本地生成与远程仓库对应的目录结构和 .git 配置。\n远程仓库管理\r查看远程\r查看当前目录下已配置的远程仓库及其地址：\ngit remote -v 添加远程\r为当前仓库注册远程地址，rep1 是起的别名，方便在后续命令里引用完整地址：　git remote add rep1 git@github.com:Hanzlgit/obsidian.git 建议将主要远程命名为 origin，对图示添加远程后 config 文件会同步记录：\n删除远程\r如果不需要某个远程，可以用别名删除它：\ngit remote rm rep1 修改远程地址\r保持远程别名不变但切换到另一个仓库：\ngit remote set-url obsidian git@github.com:Hanzlgit/obsidian.git 重命名远程\r如果只是想换一个更容易记的名字，地址保持原样：\ngit remote rename obsidian bieming1 忽略文件\r在仓库根目录创建 .gitignore，列出不想提交的文件夹或文件类型，示例内容如下：\n# 忽略特定文件夹\rfolder_name/\rbuild/\rnode_modules/\rdist/\r# 忽略特定文件\r*.log\r*.tmp 暂存与提交\r所有改动先添加到暂存区，再提交到本地仓库，最后再推送到远程。\ngit add . 添加完成后可以用 git status 看到工作区与暂存区的差异：\ngit status 本地提交命令如下：\ngit commit -m \u0026#34;first commit\u0026#34; 推送到远程\r把本地分支推送到远程（建议统一使用 origin）：\ngit push obsidian master\rgit push origin master 配置别名\r在 .git/config 或全局 C:\\Program Files\\Git\\etc\\gitconfig 里新增 [alias]，把常用命令绑定给简短名字，例如：\n[alias]\rgo = !git add . \u0026amp;\u0026amp; git commit -m \u0026#39;Auto commit\u0026#39; \u0026amp;\u0026amp; git push obsidian master 当命令需要接收参数时，可以使用 shell 函数的形式，注意符号 \\ 表示续行：\n[alias]\rgo = \u0026#34;!f() { \\\rif [ -z \\\u0026#34;$1\\\u0026#34; ]; then \\\rgit add . \u0026amp;\u0026amp; git commit -m \\\u0026#34;Auto commit\\\u0026#34; \u0026amp;\u0026amp; git push obsidian master; \\\relse \\\rgit add . \u0026amp;\u0026amp; git commit -m \\\u0026#34;$1\\\u0026#34; \u0026amp;\u0026amp; git push obsidian master; \\\rfi \\\r}; f\u0026#34; 也可以写成一行，省略换行符后的续行符：\n[alias]\rgo = \u0026#34;!f() { if [ -z \\\u0026#34;$1\\\u0026#34; ]; then git add . \u0026amp;\u0026amp; git commit -m \\\u0026#34;Auto commit\\\u0026#34; \u0026amp;\u0026amp; git push obsidian master; else git add . \u0026amp;\u0026amp; git commit -m \\\u0026#34;$1\\\u0026#34; \u0026amp;\u0026amp; git push obsidian master; fi }; f\u0026#34; 永久设置别名\r如果需要在所有仓库共用别名，请在管理员模式下编辑 C:\\Program Files\\Git\\etc\\gitconfig，确保 [alias] 区域里统一使用 origin 作为默认远程：\n[diff \u0026#34;astextplain\u0026#34;]\rtextconv = astextplain\r[filter \u0026#34;lfs\u0026#34;]\rclean = git-lfs clean -- %f\rsmudge = git-lfs smudge -- %f\rprocess = git-lfs filter-process\rrequired = true\r[http]\rsslBackend = openssl\rsslCAInfo = C:/Program Files/Git/mingw64/ssl/certs/ca-bundle.crt\r[core]\rautocrlf = true fscache = true\rsymlinks = false\r[pull]\rrebase = false\r[credential]\rhelper = manager-core\r[credential \u0026#34;https://dev.azure.com\u0026#34;]\ruseHttpPath = true\r[init]\rdefaultBranch = master\r[alias]\rgo = \u0026#34;!f() { \\\rif [ -z \\\u0026#34;$1\\\u0026#34; ]; then \\\rgit add . \u0026amp;\u0026amp; git commit -m \\\u0026#34;Auto commit\\\u0026#34; \u0026amp;\u0026amp; git push origin master; \\\relse \\\rgit add . \u0026amp;\u0026amp; git commit -m \\\u0026#34;$1\\\u0026#34; \u0026amp;\u0026amp; git push origin master; \\\rfi \\\r}; f\u0026#34; ","date":"2026-04-11T00:00:00Z","permalink":"/p/git-%E5%B8%B8%E7%94%A8%E5%91%BD%E4%BB%A4/","title":"Git 常用命令"},{"content":"Git 常见问题\r换行符提示\r在执行 git add 时看到 LF will be replaced by CRLF，说明 Git 检测到当前文件中有 Unix 风格的换行符（LF），而在 Windows 上默认会将其转换为 CRLF。这个提示并不表示操作失败，只是提醒你会发生换行符的转化。\n如何查看换行符\r用 Notepad++ 检查文件的行尾符可以直观地确认当前的换行格式：\n打开文件； 选择 视图 -\u0026gt; 显示符号 -\u0026gt; 显示行尾符； 比较不同文件在右下角和行尾显示的换行符类型。 建议策略\r如果仓库中存在 .gitattributes，优先执行其中的换行符策略。 对于跨平台协作项目，尽量统一换行符，避免提交时产生无意义的 diff。 ","date":"2026-04-11T00:00:00Z","permalink":"/p/git-%E6%8D%A2%E8%A1%8C%E7%AC%A6-lf-%E4%B8%8E-crlf-%E8%AF%B4%E6%98%8E/","title":"Git 换行符 LF 与 CRLF 说明"},{"content":"GitHub SSH 连接配置\r配置 SSH 连接可以免去每次推送时输入用户名密码，以下步骤是通过 Git Bash 在 Windows 下生成并验证 SSH key 的常规流程。\n生成 SSH Key\r在 Git Bash 中运行如下命令，会在默认位置生成一对 RSA 密钥，-t rsa -b 4096 指定算法与密钥长度，-C 后面的邮箱仅作标签用途：\nssh-keygen -t rsa -b 4096 -C \u0026#34;your_email@example.com\u0026#34; 默认会把私钥写入 /c/Users/you/.ssh/id_rsa，公钥会以 .pub 作为后缀。截图如下：\n私钥（id_rsa）不要泄露，公钥内容复制后粘贴到 GitHub 账户设置的 SSH key 页面。\n测试连接\r配置完成后可以用 ssh -T 命令验证是否已经能成功连接 GitHub：\nssh -T git@github.com 如果配置正确，命令会提示你已成功认证并显示用户名。\n","date":"2026-04-11T00:00:00Z","permalink":"/p/github-ssh-%E8%BF%9E%E6%8E%A5%E9%85%8D%E7%BD%AE/","title":"GitHub SSH 连接配置"},{"content":"概述\r博客由两部分组成，互相独立：\nmycontent/ — Obsidian 笔记，存放所有文章 myblog/ — Hugo 框架，负责生成网站 写完文章后，运行 sync.py 同步内容，再用 hugo 构建，最后上传到服务器。\n本地\r安装 Hugo\rwinget install Hugo.Hugo.Extended 安装后重启终端，验证：\nhugo version 目录结构\rA67_boke/\r├── mycontent/ ← Obsidian 笔记\r│ └── 主题/\r│ ├── 文章.md\r│ └── files/ ← 文章配图\r└── myblog/ ← Hugo 框架\r├── hugo.toml\r├── sync.py ← 同步脚本\r├── themes/\r└── content/ ← 由 sync.py 生成，不要手动修改 同步内容\rsync.py 把 mycontent/ 转成 Hugo 格式，自动处理：\n添加 front matter（标题、日期） 图片重命名（去掉空格）并复制到文章目录 cd myblog\rpython sync.py 本地预览\rhugo server 打开 http://localhost:1313 查看效果。\n构建\rhugo 生成的静态文件在 myblog/public/ 目录。\n服务器\r服务器系统：Debian，Web 服务器：Nginx。\n安装 Nginx\rapt update \u0026amp;\u0026amp; apt install nginx -y 创建站点目录\rmkdir -p /var/www/blog 配置 Nginx\r新建配置文件：\nnano /etc/nginx/sites-available/blog 写入以下内容，替换 yourdomain.com 为你的域名：\nserver { listen 80; server_name yourdomain.com; root /var/www/blog; index index.html; location / { try_files $uri $uri/ =404; } } 启用配置：\nln -s /etc/nginx/sites-available/blog /etc/nginx/sites-enabled/ nginx -t systemctl reload nginx 上传文件\r本地构建后，用 rsync 上传 public/ 目录：\nrsync -avz --delete public/ user@yourdomain.com:/var/www/blog/ --delete 会删除服务器上多余的旧文件，保持同步。\n发布流程总结\r每次写完新文章：\napt install rsync -y cd myblog python sync.py # 同步 Obsidian 内容 hugo # 构建静态文件 rsync -avz --delete public/ user@yourdomain.com:/var/www/blog/ # /mnt/c/Users/20881/vscode/A67_boke/myblog/public/ # 申请证书 apt install certbot python3-certbot-nginx -y certbot --nginx -d yourdomain.com # 输入到期提醒(90天有效期)邮箱, 同意条款 三条命令，完成发布。\n","date":"2026-04-11T00:00:00Z","permalink":"/p/hugo-%E5%8D%9A%E5%AE%A2%E6%90%AD%E5%BB%BA%E6%B5%81%E7%A8%8B/","title":"Hugo 博客搭建流程"},{"content":"IntelliJ IDEA 使用笔记\r框中选中当前项目文件 实时模板\r添加 TODO 模板 添加 模板组 user 添加模板 td 设置生效范围, 这里是 java 快捷键\rctrl+alt+B 找实现方法 ctrl+F12 查看方法和变量 滚轮+鼠标左键 或者 alt+鼠标左键 竖着选 alt+7 类文件左侧显示大纲 ctrl+alt+U 查看类图 crtl+h 展示子类 crtl+i 实现方法 maven\r使用-U, 强制检查远程仓库中是否有依赖项（特别是 `SNAPSHOT` 版本的依赖）的更新。** 它会忽略本地仓库中已有的缓存，直接向远程仓库发起请求，以确保你获取到的是最新的 `SNAPSHOT` 构建。 ","date":"2026-04-11T00:00:00Z","permalink":"/p/intellij-idea-%E4%BD%BF%E7%94%A8%E7%AC%94%E8%AE%B0/","title":"IntelliJ IDEA 使用笔记"},{"content":"Java BigDecimal 精度计算\rpublic class BigDecimalTest { public static void main(String[] args) { // 传入 字符串 可以保证精度 BigDecimal bigDecimal = new BigDecimal(\u0026#34;157.70\u0026#34;); // 传入整数 会先转换为二进制(可能有精度损失) // BigDecimal bigDecimal = new BigDecimal(157.70); BigDecimal result = bigDecimal.multiply(new BigDecimal(\u0026#34;100\u0026#34;)); System.out.println(result); } } ","date":"2026-04-11T00:00:00Z","permalink":"/p/java-bigdecimal-%E7%B2%BE%E5%BA%A6%E8%AE%A1%E7%AE%97/","title":"Java BigDecimal 精度计算"},{"content":"Java Collection 集合\r使用选择\rcollection\rCollection\u0026lt;String\u0026gt; collections = new ArrayList\u0026lt;\u0026gt;(); collections.add(\u0026#34;java\u0026#34;); collections.add(\u0026#34;c++\u0026#34;); collections.add(\u0026#34;python\u0026#34;); System.out.println(collections); //collection 中定义的是共性方法(兼顾set), 所以没有索引操作 collections.remove(\u0026#34;java\u0026#34;); System.out.println(collections.contains(\u0026#34;java\u0026#34;)); System.out.println(collections); System.out.println(collections.size()); collections.clear(); System.out.println(collections.isEmpty()); [java, c++, python] false [c++, python] 2 true equals\rcontains 方法依赖 equals 实现比较, 如果数组是自定义类型且没有实现 equals 方法, 则默认使用 Object.equals Object的equals()方法使用 ==判断, 使用地址值判断 public boolean equals(Object obj) { return (this == obj); } 因此使用 自定义类型的数组的 contains 方法时要重写 equals 方法 collection遍历\r因为是 collection, 没有索引, 因此不能使用 for 遍历; 迭代器\rIterator\u0026lt;String\u0026gt; iterator = collection.iterator(); while(iterator.hasNext()){ String str = iterator.next(); if(str.equals(\u0026#34;c++\u0026#34;)){ iterator.remove(); //删除最后返回的(是上一个,因为next已经后移了)元素 } System.out.println(str); } 在迭代器内不能使用集合的方法对集合进行增加和删除. 增强For\r修改 s 不会影响源集合中的元素 lambda\rcollection.forEach(new Consumer\u0026lt;String\u0026gt;() { @Override public void accept(String s) { System.out.println(s); } }); 转为 lambda\ncollection.forEach(s-\u0026gt; System.out.println(s)); List\r细节: 删除 遍历\r除了 collection 三种外, 还有 for(index), 列表迭代器 ArrayList\r原理 源码\r先在 main 中断下, 再在 ArrayList 中添加断点 初始化 public ArrayList() { this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;-\u0026gt;{} 这就是空的 } 第一次添加 ![[Java/Java SE/excalidraw/arrayListAdd源码.excalidraw]] 第11次添加 ![[Java/Java SE/excalidraw/ArrayList扩容.excalidraw]] linkedList\r迭代器源码\rSet\rset 中没有特有的方法 list 中 add 永远返回 true 但是 set 中根据是否添加成功返回 true 或者 false hashset\r使用自定义类型时要 重写 hashCode 和 equals 重写 hashCode 是为了确保 两个属性相同的对象在存入数组时命中同一个索引位置, 重写 equals 是为了确保在命中同一个位置之后比较元素是否相同时比较对象的属性而非地址. 为什么存和取的顺序不一样 因为取时是从数组开头开始遍历 linkedHashSet\rtreeSet\r没有特有方法 基于红黑树,不需要重写 hashCode 和 equals 多个字符也是按照 ascll 表排序, 和长度无关 比较规则\r两者都有则按照比较器排序 实现 comparable接口\rpublic class Student implements Comparable\u0026lt;Student\u0026gt;{ private String name; @Override public int compareTo(Student o) { return this.age-o.getAge(); // return 0; } } this.aget: 18, o.age: 18 this.aget: 19, o.age: 18 this.aget: 20, o.age: 18 this.aget: 20, o.age: 19 使用 Comparator\r默认类型如 String 实现自定义比较规则时, 总不能重写 String 的 compareTo 吧, 此时使用比较器\n创建 TreeSet 时添加 Comparator\n返回负数则 o1 在 o2 前面, 返回正数则 o2 在 o1 前面\nTreeSet\u0026lt;String\u0026gt; treeSetString = new TreeSet\u0026lt;\u0026gt;(new Comparator\u0026lt;String\u0026gt;() { @Override public int compare(String o1, String o2) { return o1.length()-o2.length(); // return 0; } }); ","date":"2026-04-11T00:00:00Z","permalink":"/p/java-collection-%E9%9B%86%E5%90%88/","title":"Java Collection 集合"},{"content":"Java Comparator 与 Comparable 比较器\r在 Java 中，Comparator 和 Comparable 都是用来进行对象比较的接口，但它们的使用方式和目的略有不同。下面是它们的详细对比：\n一、Comparable 接口\r包名：java.lang\n定义：对象自身具备比较能力。\n方法：int compareTo(T o)\n用途：自然排序（Natural Ordering），也就是对象默认的排序方式。\n使用示例：\rpublic class Student implements Comparable\u0026lt;Student\u0026gt; { private String name; private int score; public Student(String name, int score) { this.name = name; this.score = score; } @Override public int compareTo(Student other) { return this.score - other.score; // 按成绩升序 } } 使用：\nList\u0026lt;Student\u0026gt; list = new ArrayList\u0026lt;\u0026gt;(); list.add(new Student(\u0026#34;Tom\u0026#34;, 85)); list.add(new Student(\u0026#34;Jerry\u0026#34;, 90)); Collections.sort(list); // 按照 score 排序 二、Comparator 接口\r包名：java.util\n定义：创建一个“外部比较器”，不修改类本身。\n方法：int compare(T o1, T o2)\n用途：定制排序（Custom Ordering），多个排序方式时特别有用。\n使用示例：\rpublic class Student { private String name; private int score; public Student(String name, int score) { this.name = name; this.score = score; } public int getScore() { return score; } } // 外部比较器 class ScoreDescendingComparator implements Comparator\u0026lt;Student\u0026gt; { @Override public int compare(Student s1, Student s2) { return s2.getScore() - s1.getScore(); // 按成绩降序 } } 使用：\nList\u0026lt;Student\u0026gt; list = new ArrayList\u0026lt;\u0026gt;(); list.add(new Student(\u0026#34;Tom\u0026#34;, 85)); list.add(new Student(\u0026#34;Jerry\u0026#34;, 90)); Collections.sort(list, new ScoreDescendingComparator()); 三、对比总结\r特点 Comparable Comparator 所在包 java.lang java.util 接口方法 compareTo(T o) compare(T o1, T o2) 是否修改类代码 是（实现接口） 否（独立实现） 排序逻辑位置 类的内部 类的外部 使用场景 固定的、默认的排序逻辑 灵活的、多种排序方式 如果你想让一个类默认具备排序功能，用 Comparable； 如果你需要不同的排序方式，或者不能修改类源码，用 Comparator。\n","date":"2026-04-11T00:00:00Z","permalink":"/p/java-comparator-%E4%B8%8E-comparable-%E6%AF%94%E8%BE%83%E5%99%A8/","title":"Java Comparator 与 Comparable 比较器"},{"content":"Java File 类\r创建\r操作\rdelete 不会回收到回收站, 为空时才能删\ncreateFile 如果父级路径不存在, 会抛异常; 有返回值表示创建成功或失败\n","date":"2026-04-11T00:00:00Z","permalink":"/p/java-file-%E7%B1%BB/","title":"Java File 类"},{"content":"Java IO 流\r字节流\rfileOutputStream\r程序中传入输出流的数据是byte类型 public static void main(String[] args) throws IOException { FileOutputStream fos = new FileOutputStream(\u0026#34;src/io/a.text\u0026#34;); fos.write(97); fos.write({97,98,99},0,3); fos.write(\u0026#34;\\r\\n\u0026#34;.getBytes()) fos.close(); } fileinputStream\r程序从输入流中获取的数据是 byte类型 读不到返回 -1 FileInputStream fis = new FileInputStream(\u0026#34;src/io/a.text\u0026#34;); int b = fis.read(); System.out.println(b); 97 fis.close(); // 循环读取 int b ; while((b= fis.read())!=-1){ System.out.print((char) b ); } demo\r文件拷贝\rFileInputStream fis = new FileInputStream(\u0026#34;src/io/a.text\u0026#34;); FileOutputStream fos = new FileOutputStream(\u0026#34;src/io/b.text\u0026#34;); int b; while((b= fis.read())!=-1){ fos.write(b); } fos.close(); fis.close(); 文件拷贝读数组\rFileOutputStream fos = new FileOutputStream(\u0026#34;src/io/b.text\u0026#34;); byte[] b = new byte[10]; while((fis.read(b))!=-1){ fos.write(b,0,b.length); } fos.close(); fis.close(); 编码\rascll\r这些符号占一个字节 gbk\r汉字gbk中是两个字节 第一个字节转为十进制是负数 unicode\rutf-8\r进行编码有两步: 查表, 填充 因此解码也有两步: 取值, 查表\n查 unicode 后对 x 进行填补 例如: 汉查 unicode 后是 27721-\u0026gt;01101100 01001001, 存到 xxx 字符流\r底层是字节流 默认一次读取一个字节, 遇到中文时一次读取多个(gbk:2, utf-8:3) fileReader\r默认一次读取一个字节, 遇到中文时一次读取多个(gbk:2, utf-8:3), 读完后解码转为十进制, 这个十进制是字符集上的数字 例如: 汉在文件中存储的是下面的3个字节, 但是会将黑色的数字拿出来排列成两个字节=\u0026gt; 01101100 01001001 用 int 解读数字是 27721 read 只是进行了解码操作中 的取值操作, 并没有进行查表; 得到的是取值后的数,要得到字符要进行查表(强转) FileReader fr = new FileReader(\u0026#34;src/io/aReader.text\u0026#34;); // 默认一次读一个字节, 遇到中文一次读多个字节, utf-8:3 这里只 取值, 不查表 int ch; while((ch = fr.read())!=-1){ System.out.println(ch); } 27721 汉 20320 你 22909 好 13 0d 10 0a int len; char[] chars = new char[2]; while((len = fr.read(chars))!=-1){ // 这里不仅取值, 也进行了查表操作 System.out.print(new String(chars,0,len)); } fileWriter\rint 是先将 int 编码,再写入 字符流原理\r输入流\r输出流\rdemo\r文件夹拷贝\rpublic static void main(String[] args) throws IOException { File src = new File(\u0026#34;src/io/test\u0026#34;); File dst = new File(\u0026#34;src/io/testDest\u0026#34;); copy(src,dst); } private static void copy(File src, File dst) throws IOException { dst.mkdirs(); File[] files = src.listFiles(); for (File file : files) { if(file.isFile()){ //拷贝文件 String fileName = file.getName(); File srcFile = new File(src,fileName); //parent/a.text File destFile = new File(dst, fileName);//another/a.text copyWrite(srcFile,destFile); }else{ // 拷贝文件夹 String fileName = file.getName(); File srcFile = new File(src,fileName); //parent/aFolder File destFile = new File(dst, fileName);//another/bFolder copy(srcFile,destFile ); } } } private static void copyWrite(File srcFile, File destFile) throws IOException { FileInputStream fis = new FileInputStream(srcFile); FileOutputStream fos = new FileOutputStream(destFile); int len; byte[] bytes = new byte[1024]; while((len = fis.read(bytes))!=-1){ fos.write(bytes,0,len); } fos.close(); fis.close(); } 缓冲流\r字节缓冲流\r可以手动设置缓冲区大小 字节缓冲流的方法和字节流一样, 返回的都是字节 左边空了就从硬盘中重新获取, 右边满了就写到硬盘 字符缓冲流\r字符流自带缓冲区, 但是字符缓冲流自带两个方法 BufferedReader br = new BufferedReader(new FileReader(\u0026#34;src/io/a.text\u0026#34;)); String s; while((s = br.readLine())!=null){ System.out.println(s); } br.close(); BufferedWriter bw = new BufferedWriter(new FileWriter(\u0026#34;src/io/a.text\u0026#34;,true)); bw.newLine(); 转换流\r转换流就是字符流 序列化流\r将 java 对象写到本地文件, 从本地文件读取到 java 对象中. 版本号问题 可以固定版本号\n打印流\r字节打印流\r压缩\r解压缩流\rpublic static void main(String[] args) throws IOException{ File src = new File(\u0026#34;src/io/test.zip\u0026#34;); File dst = new File(\u0026#34;src/io/testZip/\u0026#34;); unzip(src,dst); } public static void unzip(File src, File dst) throws IOException { ZipInputStream zip = new ZipInputStream(new FileInputStream(src)); ZipEntry zipEntry ; while ((zipEntry = zip.getNextEntry())!=null){ if(zipEntry.isDirectory()){ // 文件夹 File dir = new File(dst, zipEntry.toString()); System.out.println(dir.toString()); dir.mkdirs(); }else{ //文件 FileOutputStream fos = new FileOutputStream(new File(dst, zipEntry.toString())); int b; while((b = zip.read())!=-1){ fos.write(b); } fos.close(); // 压缩包中的一个文件处理完毕了 zip.closeEntry(); } } zip.close(); } 压缩流\r单个文件\r/** * * @param src a.text * @param dst src/io/ */public static void toZip(File src, File dst) throws IOException { File file = new File(dst, \u0026#34;a.zip\u0026#34;); ZipOutputStream zipOut = new ZipOutputStream(new FileOutputStream(file)); // 使压缩包有响应的结构 ZipEntry zipEntry = new ZipEntry(\u0026#34;a.text\u0026#34;); zipOut.putNextEntry(zipEntry); FileInputStream fis = new FileInputStream(src); int b; while((b = fis.read())!=-1){ zipOut.write(b); } fis.close(); zipOut.closeEntry(); zipOut.close(); } 在放到 zipEntry 之后直接向 zipOut 中写就是向刚刚的 zipEntry 中写 压缩文件夹\rFile src1 = new File(\u0026#34;src/io/test\u0026#34;); File dst1 = new File(\u0026#34;src/io/test1.zip\u0026#34;); ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(dst1)); dirToZip(src1, zos, \u0026#34;test\u0026#34;); zos.close(); /** * 把src 目录下的文件和文件夹 向 压缩包内部 innerPath写 * * @param src /src/io/test 把这个文件夹吓得内容进行移动 * @param zos /src/io/test1.zip 因为要递归所以第二个参数不能是zip, 得是流 * @param innerPath 压缩包内部文件夹 */ public static void dirToZip(File src, ZipOutputStream zos, String innerPath) throws IOException { File[] files = src.listFiles(); for (File file : files) { if (file.isFile()) { // System.out.println(file.getName()); System.out.println(innerPath + \u0026#34;\\\\\u0026#34; + file.getName()); ZipEntry zipEntry = new ZipEntry(innerPath + \u0026#34;\\\\\u0026#34; + file.getName()); zos.putNextEntry(zipEntry); FileInputStream fileInputStream = new FileInputStream(file); int b; while((b = fileInputStream.read())!=-1){ zos.write(b); } fileInputStream.close(); zos.closeEntry(); } else { File file1 = new File(src.toString() + \u0026#34;\\\\\u0026#34; + file.getName()); dirToZip(file1,zos,innerPath + \u0026#34;\\\\\u0026#34; + file.getName()); } } } ","date":"2026-04-11T00:00:00Z","permalink":"/p/java-io-%E6%B5%81/","title":"Java IO 流"},{"content":"Java IO 流进阶笔记\r转换流\r程序中操作的是字符流（转码后），但与文件交互时底层仍是字节流。\nInputStreamReader(InputStream in, \u0026quot;UTF-8\u0026quot;) 会把字节流转换为字符流。比如文件中有中文“我”，如果直接用 FileInputStream.read() 逐字节读取并强转 char，会出现乱码，因为 UTF-8 下一个中文通常由 3 个字节组成；而用 InputStreamReader 读取时，会先完成解码再返回字符。 同理，向文件写入“我”时，底层需要写入 3 个字节；这个编码过程由 OutputStreamWriter 完成，它会把字符转换后再写入 OutputStream。 缓冲流\r缓冲流的核心作用是提升读写效率：读时先把文件数据读入内存缓冲区，写时先写入缓冲区，缓冲区满后再批量写入文件系统。\n字节缓冲流 BufferedInputStream(InputStream in) 字符缓冲流: readLine() newLine(); 字符缓冲流：readLine()、newLine() 使用缓冲流写文件时，如果数据未填满缓冲区，需要手动调用 flush()。\n","date":"2026-04-11T00:00:00Z","permalink":"/p/java-io-%E6%B5%81%E8%BF%9B%E9%98%B6%E7%AC%94%E8%AE%B0/","title":"Java IO 流进阶笔记"},{"content":"Java Lambda 表达式\r简化匿名内部类\r函数式接口\r只有一个方法需要重写, 那么这个方法名可以省略, 因此可以使用 lambda 表达式 必须是接口, 抽象类不可以 demo\rString[] str = {\u0026#34;a\u0026#34;,\u0026#34;aaa\u0026#34;,\u0026#34;aa\u0026#34;,\u0026#34;a\u0026#34;}; Arrays.sort(str, new Comparator\u0026lt;String\u0026gt;() { @Override public int compare(String o1, String o2) { return o1.length()-o2.length(); // return 0; } }); Arrays.sort(str,(s1,s2)-\u0026gt; s1.length()-s2.length()); ","date":"2026-04-11T00:00:00Z","permalink":"/p/java-lambda-%E8%A1%A8%E8%BE%BE%E5%BC%8F/","title":"Java Lambda 表达式"},{"content":"Java Map 集合\rput 会覆盖之前的 value 并返回原来的 value 遍历方法\r键找值\rkeySet() Set\u0026lt;String\u0026gt; strings = map.keySet(); for (String string : strings) { String value = map.get(string); System.out.println(value); } Iterator\u0026lt;String\u0026gt; iterator = strings.iterator(); while(iterator.hasNext()){ String next = iterator.next(); System.out.println(map.get(next)); } strings.forEach(new Consumer\u0026lt;String\u0026gt;() { @Override public void accept(String s) { System.out.println(map.get(s)); } }); 键值对\rentrySet() Set\u0026lt;Map.Entry\u0026lt;String, String\u0026gt;\u0026gt; entries = map.entrySet(); Iterator\u0026lt;Map.Entry\u0026lt;String, String\u0026gt;\u0026gt; iterator = entries.iterator(); while(iterator.hasNext()){ Map.Entry\u0026lt;String, String\u0026gt; next = iterator.next(); System.out.println(next.getKey()+\u0026#34;::\u0026#34;+next.getValue()); } HashMap\rput 方法在元素相等时仍然是覆盖, 否则和 HashSet 一样存成链表转为红黑树 LinkedHashMap\rTreeMap\r两种比较规则\r使用第一种时, 要求 map 的 key 是自定义类型 TreeMap\u0026lt;Student,String\u0026gt; treeMap = new TreeMap\u0026lt;\u0026gt;() Comparator 泛型是 key 的类型, 比较只是比较 key TreeMap\u0026lt;Integer,String\u0026gt; treeMap = new TreeMap\u0026lt;\u0026gt;(new Comparator\u0026lt;Integer\u0026gt;() { @Override public int compare(Integer o1, Integer o2) { return o2-o1; } }); 下面补充说明“重哈希定位更高效”的含义。\n当 HashMap 中的元素数量超过了阈值（默认是容量的 0.75 倍）时，就会触发扩容。扩容 = 数组容量翻倍 + 所有元素重新计算位置，这个过程就叫做 rehash（重哈希）。\n那为什么容量是 2 的 n 次方会让重哈希更高效？\r1. 不用重新计算完整哈希\r当容量从 n 扩容为 2n 时，原来元素的哈希值 hash 并不用全部重新计算，只需要看它的“新增那一位”是 0 还是 1。\n假设原容量是 16（即 2⁴），扩容后是 32（2⁵）：\n原来索引是 index = hash \u0026amp; 15（0b1111）\n扩容后索引是 index = hash \u0026amp; 31（0b11111）\n也就是说，只需要判断第 5 位是 0 还是 1：\n如果是 0，那么元素还在原来的桶里\n如果是 1，它就被挪到 “原位置 + 原容量” 的桶里\n这就是为什么源码中有一段逻辑是这样的（JDK 8 中）：\nif ((e.hash \u0026amp; oldCap) == 0) e.next = newTable[j]; // 留在原位置 else e.next = newTable[j + oldCap]; // 移到新位置 2. 节省大量计算成本\r因为不需要重新计算哈希值，也不需要重新做 mod 操作，仅靠一位 \u0026amp; 运算就能决定新位置，所以扩容时的计算量大大减少，效率很高。\n总结一句话：\r使用 2 的 n 次方作为数组大小，可以让 HashMap 扩容时通过判断哈希值的某一位，快速决定元素是否需要移动位置，且不需要重新算完整的哈希和取模，这就是“重哈希定位更高效”的核心原因。\n以上就是“重哈希定位更高效”的核心原因。\n","date":"2026-04-11T00:00:00Z","permalink":"/p/java-map-%E9%9B%86%E5%90%88/","title":"Java Map 集合"},{"content":"Java Stream API\r获取 stream 流\rpublic static void main(String[] args) { ArrayList\u0026lt;String\u0026gt; list = new ArrayList\u0026lt;\u0026gt;(); Collections.addAll(list,\u0026#34;小寒\u0026#34;,\u0026#34;小李\u0026#34;,\u0026#34;小红\u0026#34; ); Stream\u0026lt;String\u0026gt; stream = list.stream(); stream.forEach(s -\u0026gt; System.out.println(s)); Map\u0026lt;Integer,String\u0026gt; map = new HashMap\u0026lt;\u0026gt;(); map.put(1,\u0026#34;java\u0026#34;); map.put(2,\u0026#34;c++\u0026#34;); map.put(3,\u0026#34;python\u0026#34;); map.keySet().stream().forEach(i-\u0026gt; System.out.println(i)); map.entrySet().stream().forEach(s-\u0026gt; System.out.println(s)); int[] arr = {1,2,3,4,5,6}; Arrays.stream(arr).forEach(s-\u0026gt; System.out.println(s)); Stream.of(1,2,3,4,5,\u0026#34;k\u0026#34;).forEach(s-\u0026gt; System.out.println(s)); } Stream.of() 参数可以是一堆零散数据, 也可以是引用数据数组 中间方法\rfilter 匿名内部类中 test() 函数返回 true留下,否则去掉 list.stream() .filter(s -\u0026gt; {return s.contains(\u0026#34;红\u0026#34;);}) .forEach(s -\u0026gt; System.out.println(s)); Stream.concat(list.stream(),list.stream()).forEach(s-\u0026gt; System.out.println(s)); // 将 String 类型转为 Integer .map(new Function\u0026lt;String, Integer\u0026gt;() { @Override public Integer apply(String s) { return Objects.hash(s); } }) 终结方法\rtoArray() 需要的参数是个函数: 返回指定类型的数组 String[] array = list.stream().toArray(new IntFunction\u0026lt;String[]\u0026gt;() { @Override public String[] apply(int value) { // value 是流上的元素个数 return new String[value]; } }); 收集到 集合 list.stream().collect(Collectors.toList()); list.stream().collect(Collectors.toSet()); //自带去重 // collect 接受两个函数接口 1:将流中的数据转为key, 2:将流中的数据转为value Map\u0026lt;String, Integer\u0026gt; collect = list.stream().collect(Collectors.toMap(new Function\u0026lt;String, String\u0026gt;() { @Override public String apply(String s) { return s; } }, new Function\u0026lt;String, Integer\u0026gt;() { @Override public Integer apply(String s) { return Objects.hash(s); } })); 分组 ","date":"2026-04-11T00:00:00Z","permalink":"/p/java-stream-api/","title":"Java Stream API"},{"content":"Java Web JWT 认证\rJwt\rjjwt https://github.com/jwtk/jjwt?tab=readme-ov-file#what-is-a-json-web-token 分类\rjws 签名 payload 不分只是编码不加密 jwe 加密 jws\r加密(对称加密)\rHeader\rString jwt = Jwts.builder() .header() // \u0026lt;---- .keyId(\u0026#34;aKeyId\u0026#34;) .x509Url(aUri) .add(\u0026#34;someName\u0026#34;, anyValue) //自定义头部参数 .add(mapValues) // ... etc ... .and() // go back to the JwtBuilder .subject(\u0026#34;Joe\u0026#34;) // resume JwtBuilder calls... // ... etc ... .compact(); 不需要设置alg、enc或zip标题 - JJWT 将始终根据需要自动设置它们。 payload\rcontent\r字节数组 byte[] content = \u0026#34;Hello World\u0026#34;.getBytes(StandardCharsets.UTF_8); String jwt = Jwts.builder() .content(content, \u0026#34;text/plain\u0026#34;) // \u0026lt;--- // ... etc ... .build(); claims\r标准claims: issure, subject, audience, expiration, notBefore, issuedAt, id 自定义 String jws = Jwts.builder() .claim(\u0026#34;hello\u0026#34;, \u0026#34;world\u0026#34;) // ... etc ... map Map\u0026lt;String,?\u0026gt; claims = getMyClaimsMap(); //implement me String jws = Jwts.builder() .claims(claims) // ... etc ... 加密秘钥\r自动生成 SecretKey key = Jwts.SIG.HS256.key().build(); //or HS384.key() or HS512.key() // 转成base64后再存储 String secretString = Encoders.BASE64.encode(key.getEncoded()); 手动指定\n编码的字节数组：\nSecretKey key = Keys.hmacShaKeyFor(encodedKeyBytes); Base64 编码的字符串：\nSecretKey key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretString)); Base64URL 编码的字符串：\nSecretKey key = Keys.hmacShaKeyFor(Decoders.BASE64URL.decode(secretString)); 原始（非编码）字符串（例如密码字符串）：\nPassword key = Keys.password(secretString.toCharArray()); Keys.hmacShaKeyFor(secretString.toCharArray()) ```\n加密方法\r常见签名算法 io.jsonwebtoken.Jwts.SIG 类中 标识符 签名算法 HS256 使用 SHA-256 的 HMAC HS384 使用 SHA-384 的 HMAC HS512 使用 SHA-512 的 HMAC ES256 使用 P-256 和 SHA-256 的 ECDSA ES384 使用 P-384 和 SHA-384 的 ECDSA ES512 使用 P-521 和 SHA-512 的 ECDSA RS256 使用 SHA-256 的 RSASSA-PKCS-v1_5 RS384 使用 SHA-384 的 RSASSA-PKCS-v1_5 RS512 使用 SHA-512 的 RSASSA-PKCS-v1_5 PS256 RSASSA-PSS 使用 SHA-256 和 MGF1 与 SHA-256 1 PS384 RSASSA-PSS 使用 SHA-384 和 MGF1 与 SHA-384 1 PS512 RSASSA-PSS 使用 SHA-512 和 MGF1 与 SHA-512 1 EdDSA Edwards-Curve 数字签名算法 (EdDSA) 2 秘钥的长度有规定\nHS256是 HMAC-SHA-256，它产生的摘要长度为 256 位（32 字节），因此HS256 _要求_您使用长度至少为 32 字节的密钥。\nHS384是 HMAC-SHA-384，它产生的摘要长度为 384 位（48 字节），因此HS384 _要求_您使用长度至少为 48 字节的密钥。\nHS512是 HMAC-SHA-512，它产生的摘要长度为 512 位（64 字节），因此HS512 _要求_您使用长度至少为 64 字节的密钥。\n示例\n// 不指定加密方法 jwts会自动指定, 并写到头部. .signWith(privateKey, Jwts.SIG.RS512) // \u0026lt;--- .compact(); 完整实例\rSecretKey secretKey1 = Keys.hmacShaKeyFor(secretKey.getBytes()); JwtBuilder builder = Jwts.builder() .signWith(secretKey1, Jwts.SIG.HS256) // 设置过期时间 .expiration(exp) .claims(claims); return builder.compact(); 解密(对称加密)\rcontent\r// create a test key for this example: SecretKey testKey = Jwts.SIG.HS512.key().build(); String message = \u0026#34;Hello World. It\u0026#39;s a Beautiful Day!\u0026#34;; byte[] content = message.getBytes(StandardCharsets.UTF_8); String jws = Jwts.builder().signWith(testKey) // #1 .content(content) // #2 .encodePayload(false) // #3 .compact(); Jws\u0026lt;byte[]\u0026gt; parsed = Jwts.parser().verifyWith(testKey) // 1 .build() .parseSignedContent(jws, content); // 2 assertArrayEquals(content, parsed.getPayload()); claims\rJwts.parser() .verifyWith(secretKey) // \u0026lt;---- .build() .parseSignedClaims(jwsString); .getPayload(); return payload; ","date":"2026-04-11T00:00:00Z","permalink":"/p/java-web-jwt-%E8%AE%A4%E8%AF%81/","title":"Java Web JWT 认证"},{"content":"多线程 HTTP 服务示例\r这个示例演示如何用 ServerSocket 接收 HTTP 请求，并为每个连接创建一个线程处理请求。\n服务端入口\rimport java.io.IOException; import java.net.ServerSocket; import java.net.Socket; public class Server { public static void main(String[] args) throws IOException { ServerSocket serverSocket = new ServerSocket(8080); while (true) { Socket socket = serverSocket.accept(); Thread thread = new Thread(new MyRunnable(socket)); thread.start(); } } } 请求处理线程\rimport java.io.BufferedReader; import java.io.BufferedWriter; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStreamReader; import java.io.OutputStreamWriter; import java.net.Socket; import java.nio.charset.StandardCharsets; public class MyRunnable implements Runnable { private final Socket socket; public MyRunnable(Socket socket) { this.socket = socket; } @Override public void run() { try { handle(); } catch (IOException e) { throw new RuntimeException(e); } } private void handle() throws IOException { try ( BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8)) ) { String firstLine = reader.readLine(); boolean isHomeRequest = firstLine != null \u0026amp;\u0026amp; firstLine.startsWith(\u0026#34;GET / HTTP/1.\u0026#34;); String header; while ((header = reader.readLine()) != null \u0026amp;\u0026amp; !header.isEmpty()) { System.out.println(header); } if (!isHomeRequest) { writer.write(\u0026#34;HTTP/1.0 404 Not Found\\r\\n\u0026#34;); writer.write(\u0026#34;Content-Length: 0\\r\\n\u0026#34;); writer.write(\u0026#34;\\r\\n\u0026#34;); writer.flush(); return; } String html = readHtml(\u0026#34;HttpDemo/src/html.html\u0026#34;); int length = html.getBytes(StandardCharsets.UTF_8).length; writer.write(\u0026#34;HTTP/1.0 200 OK\\r\\n\u0026#34;); writer.write(\u0026#34;Content-Length: \u0026#34; + length + \u0026#34;\\r\\n\u0026#34;); writer.write(\u0026#34;Content-Type: text/html; charset=utf-8\\r\\n\u0026#34;); writer.write(\u0026#34;\\r\\n\u0026#34;); writer.write(html); writer.flush(); } } private String readHtml(String path) throws IOException { StringBuilder builder = new StringBuilder(); try ( FileInputStream inputStream = new FileInputStream(path); BufferedReader htmlReader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8)) ) { String line; while ((line = htmlReader.readLine()) != null) { builder.append(line); } } return builder.toString(); } } 注意点\raccept() 会阻塞，直到有新的客户端连接。 每个请求都创建新线程，适合理解原理；真实项目应使用线程池。 Content-Length 需要计算 UTF-8 编码后的字节数，而不是字符串长度。 响应头结束后必须写入一个空行，也就是 \\r\\n。 ","date":"2026-04-11T00:00:00Z","permalink":"/p/java-%E5%A4%9A%E7%BA%BF%E7%A8%8B-http-%E6%9C%8D%E5%8A%A1%E7%A4%BA%E4%BE%8B/","title":"Java 多线程 HTTP 服务示例"},{"content":"Java 多线程基础\r实现方式\rRunnable 接口\rpackage com.hzl.duoxiancheng.Runnable; public class MyRunnable implements Runnable{ @Override public void run() { for (int i = 0; i \u0026lt; 100; i++) { System.out.println(\u0026#34;running\u0026#34;); } } } package com.hzl.duoxiancheng.Runnable; public class MainTest { public static void main(String[] args) { MyRunnable instance = new MyRunnable(); Thread thread = new Thread(instance); thread.start(); } } Callable\rpackage com.hzl.duoxiancheng.Callable; import java.util.concurrent.Callable; public class MyCallable implements Callable { @Override public Object call() throws Exception { for (int i = 0; i \u0026lt; 100; i++) { System.out.println(\u0026#34;calling\u0026#34;); } return \u0026#34;thread run end\u0026#34;; } } package com.hzl.duoxiancheng.Callable; import java.util.concurrent.ExecutionException; import java.util.concurrent.FutureTask; public class MainTest { public static void main(String[] args) throws ExecutionException, InterruptedException { MyCallable myCallable = new MyCallable(); FutureTask futureTask = new FutureTask\u0026lt;\u0026gt;(myCallable); Thread thread = new Thread(futureTask); thread.start(); System.out.println(futureTask.get()); } } 继承 Thread 类\rpublic class MyThread extends Thread { public void run() { // ... } } https://pdai.tech/md/java/thread/java-thread-x-thread-basic.html#sleep\n生命周期\r同步代码块\rpackage com.hzl.learn.A03BingFa.A01ChangJing; import java.util.Timer; public class Main { public static void main(String[] args) { class Count implements Runnable{ static int count = 0; static Object obj= new Object(); @Override public void run() { while (true){ synchronized (obj){ if(count\u0026lt;1000){ try { Thread.sleep(20); } catch (InterruptedException e) { throw new RuntimeException(e); } count++; System.out.println(Thread.currentThread().getName()+\u0026#34;正在卖第\u0026#34;+count+\u0026#34;张票\u0026#34;); } } } } } Count count = new Count(); new Thread(count).start(); new Thread(count).start(); new Thread(count).start(); } } lock\rpackage com.hzl.learn.A03BingFa.A01ChangJing; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; public class LockTest { public static void main(String[] args) { class Count implements Runnable{ static int count = 0; // static Object obj= new Object(); static Lock lock = new ReentrantLock(); @Override public void run() { while (true){ // synchronized (obj){ lock.lock(); if(count\u0026lt;1000){ try { Thread.sleep(20); } catch (InterruptedException e) { throw new RuntimeException(e); } count++; System.out.println(Thread.currentThread().getName()+\u0026#34;正在卖第\u0026#34;+count+\u0026#34;张票\u0026#34;); }else { lock.unlock(); break; } // } lock.unlock(); } } } Count count = new Count(); new Thread(count).start(); new Thread(count).start(); new Thread(count).start(); } } 等待唤醒\rpackage com.hzl.learn.A03BingFa.A01ChangJing; import java.util.LinkedList; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; public class WaitTest { public static void main(String[] args) { // 保证同一时间只有一个线程访问 buffer Object BufferLock = new Object(); // 存放的队列 LinkedList\u0026lt;String\u0026gt; strings = new LinkedList\u0026lt;\u0026gt;(); class Consumer implements Runnable{ @Override public void run() { while (true){ // 生产者和消费者同时修改 buffer, 枷锁 synchronized (BufferLock){ if (strings.isEmpty()){ try { BufferLock.wait(); } catch (InterruptedException e) { throw new RuntimeException(e); } }else{ String s = strings.removeLast(); System.out.println(\u0026#34;消费者消费:\\t\u0026#34;+s); // 已经有空位了, 唤醒生产者 BufferLock.notifyAll(); } } } } } class Producer implements Runnable{ private int maxCount = 100; private int itemIndex = 0; @Override public void run() { while (true){ if(itemIndex\u0026gt;maxCount) break; synchronized (BufferLock){ if(strings.size()==10){ try { BufferLock.wait(); } catch (InterruptedException e) { throw new RuntimeException(e); } }else { String s = \u0026#34;item--\u0026#34;+itemIndex; itemIndex++; strings.addLast(s); BufferLock.notifyAll(); } } } System.out.println(\u0026#34;生产者停止了工作---------\u0026#34;); } } new Thread(new Consumer()).start(); new Thread(new Producer()).start(); } } 基于 信号量的同步问题\rpackage com.hzl.learn.A03BingFa.A01ChangJing; import java.util.LinkedList; import java.util.concurrent.Semaphore; public class XinHaoLiang { /** * 基于信号量的 生产者消费者 */ public static void main(String[] args) { Semaphore empty = new Semaphore(5); //剩余位置 Semaphore mutex = new Semaphore(1); // 互斥信号量, 控制并发访问 Semaphore isFilled = new Semaphore(0); // 控制生产者是否生产 LinkedList\u0026lt;String\u0026gt; strings = new LinkedList\u0026lt;\u0026gt;(); // 存放队列 class Consumer implements Runnable{ @Override public void run() { while (true){ // 请求一个有货物的位置 try { isFilled.acquire(); mutex.acquire(); } catch (InterruptedException e) { throw new RuntimeException(e); } String s = strings.removeLast(); System.out.println(\u0026#34;消费者消费:\\t\u0026#34;+s); mutex.release(); // 消费了一个, 所以empty++ empty.release(); } } } class Producer implements Runnable{ private int itemIndex = 0; private int maxCount =100; @Override public void run() { while (itemIndex\u0026lt;maxCount){ // 生产者生产, 请求一个空位存放货物 try { empty.acquire(); mutex.acquire(); } catch (InterruptedException e) { throw new RuntimeException(e); } String s = \u0026#34;item--\u0026#34;+itemIndex; itemIndex++; strings.addLast(s); mutex.release(); // 存有的数量 ++ isFilled.release(); } System.out.println(\u0026#34;生产者停止了工作\u0026#34;); } } new Thread(new Producer()).start(); new Thread(new Consumer()).start(); } } 阻塞队列\r","date":"2026-04-11T00:00:00Z","permalink":"/p/java-%E5%A4%9A%E7%BA%BF%E7%A8%8B%E5%9F%BA%E7%A1%80/","title":"Java 多线程基础"},{"content":"Java 多线程进阶笔记\r线程启动\r第三种可以获得结果 继承Thread类, 不能继承其他类. Runable接口, 扩展性强. 第一种方式\rpackage com.learn2.A10DuoXianCheng; public class Mythread extends Thread{ @Override public void run() { for (int i = 0; i \u0026lt; 100; i++) { System.out.println(getName()+\u0026#34;hello world\u0026#34;); } } } Mythread t1 = new Mythread(); t1.setName(\u0026#34;t1\u0026#34;); t1.start(); 第二种方式\rpackage com.learn2.A10DuoXianCheng; public class T2Myrun implements Runnable { @Override public void run() { Thread t = Thread.currentThread(); for (int i = 0; i \u0026lt; 100; i++) { System.out.println(t.getName()+\u0026#34;hello world\u0026#34;); } } } T2Myrun myrun = new T2Myrun(); Thread t1 = new Thread(myrun); t1.setName(\u0026#34;t1\u0026#34;); 第三种方式\rpackage com.learn2.A10DuoXianCheng; import java.util.concurrent.Callable; //泛型是结果的类型. public class MyCallable implements Callable\u0026lt;Integer\u0026gt; { @Override public Integer call() throws Exception { int sum = 0; for (int i = 1; i \u0026lt;= 100; i++) { sum+=i; } return sum; } } //创建Mycallable 继承 Callable接口， 实现 call方法有返回值 //创建Mycallable对象, 创建FutureTask对象(管理多线程执行结果的) //创建thread对象,启动 MyCallable mc = new MyCallable(); FutureTask\u0026lt;Integer\u0026gt; ft = new FutureTask\u0026lt;\u0026gt;(mc); Thread t1 = new Thread(ft); t1.start(); System.out.println(ft.get()); 成员方法\r","date":"2026-04-11T00:00:00Z","permalink":"/p/java-%E5%A4%9A%E7%BA%BF%E7%A8%8B%E8%BF%9B%E9%98%B6%E7%AC%94%E8%AE%B0/","title":"Java 多线程进阶笔记"},{"content":"Java 反射机制\r动态代理\rdemo\r","date":"2026-04-11T00:00:00Z","permalink":"/p/java-%E5%8F%8D%E5%B0%84%E6%9C%BA%E5%88%B6/","title":"Java 反射机制"},{"content":"Java 泛型\r概览\r不能写基本数据类型, 因为他们不能转为 Object 传递数据可以传递其子类类型 不写泛型, 默认 Object 泛型\r泛型类\r类名后面的 \u0026lt;\u0026gt; /** * 泛型集合 * @param \u0026lt;T\u0026gt; 数据类型 */ public class MyArrayList \u0026lt;T\u0026gt;{ Object[] arr= new Object[10]; int size; public boolean add(T t){ arr[size] = t; size++; return true; } public T get(int index){ return (T) arr[index]; } public String toString(){ return Arrays.toString(arr); } } 泛型方法\r修饰符\u0026lt;T\u0026gt; returnType method() 类中只有少量方法需要使用不确定的类型 public\u0026lt;T\u0026gt; T method(T t){ return t; } public static\u0026lt;T\u0026gt; void addAll(ArrayList\u0026lt;T\u0026gt; arrayList,T t1,T t2){ arrayList.add(t1); arrayList.add(t2); } public static\u0026lt;T\u0026gt; void addAll(ArrayList\u0026lt;T\u0026gt; arrayList,T...t){ for (T t1 : t) { arrayList.add(t1); } } 泛型接口\r实现类给出类型\rpublic class MyInterfaceFanXing implements List\u0026lt;String\u0026gt; { } //使用 MyInterfaceFanXing list = new MyInterfaceFanXing(); 类不确定泛型\rpublic class MyInterfaceFanXing\u0026lt;E\u0026gt; implements List\u0026lt;E\u0026gt; { } //使用时指定类型 MyInterfaceFanXing\u0026lt;String\u0026gt; list = new MyInterfaceFanXing(); 泛型通配符\r泛型填什么就只能是什么 限定泛型的类型\r使用 ? 不限定泛型类型 public void method2(ArrayList\u0026lt;?\u0026gt; arrayList){ } \u0026lt;? extends YeYe\u0026gt; 类型必须是继承 YeYe类 的 public void method2(ArrayList\u0026lt;? extends YeYe\u0026gt; arrayList){ } \u0026lt;? super YeYe\u0026gt; 类型必须是 YeYe 的父类 public void method2(ArrayList\u0026lt;? super YeYe\u0026gt; arrayList){ } ","date":"2026-04-11T00:00:00Z","permalink":"/p/java-%E6%B3%9B%E5%9E%8B/","title":"Java 泛型"},{"content":"Java 方法\r数据类型\r基本数据类型: 四类8种: 整,浮,char,boolen 赋值给其他变量, 直接把字面量赋值给其他变量 引用数据类型: new 出来的数据类型. 赋值给其他变量, 赋值的是地址值. 值传递\r引用数据类型传递地址 值传递内存\r","date":"2026-04-11T00:00:00Z","permalink":"/p/java-%E6%96%B9%E6%B3%95/","title":"Java 方法"},{"content":"Java 方法引用\rdemo\rTreeSet\u0026lt;String\u0026gt; treeSet = new TreeSet\u0026lt;\u0026gt;(new Comparator\u0026lt;String\u0026gt;() { @Override public int compare(String o1, String o2) { return 0; } }); Comparator 需要一个 传参是两个 String 且返回 int 的函数 public class main { public static void main(String[] args) { TreeSet\u0026lt;String\u0026gt; treeSet = new TreeSet\u0026lt;\u0026gt;(Utils::method); treeSet.add(\u0026#34;a\u0026#34;); treeSet.add(\u0026#34;ab\u0026#34;); treeSet.add(\u0026#34;c\u0026#34;); System.out.println(treeSet); } } package fangfayinyong; public class Utils { public static Integer method(String a,String b){ return a.length()-b.length(); } } 示例\rArrayList\u0026lt;String\u0026gt; list= new ArrayList\u0026lt;\u0026gt;(); Collections.addAll(list,\u0026#34;1\u0026#34;,\u0026#34;2\u0026#34;,\u0026#34;3\u0026#34;); list.stream().map(new Function\u0026lt;String, Integer\u0026gt;() { @Override public Integer apply(String s) { return Integer.parseInt(s); } }).forEach(s-\u0026gt; System.out.println(s)); 只有一个方法需要重写, 是函数接口 方法参数是 String 返回是 Integer, 找一个方法满足类型条件即可 这里使用 Integer.parseInt() // 只看传参和返回值类型即可 public static int parseInt(String s) throws NumberFormatException { return parseInt(s,10); } ArrayList\u0026lt;String\u0026gt; list= new ArrayList\u0026lt;\u0026gt;(); Collections.addAll(list,\u0026#34;1\u0026#34;,\u0026#34;2\u0026#34;,\u0026#34;3\u0026#34;); list.stream().map(Integer::parseInt).forEach(s-\u0026gt; System.out.println(s)); 引用构造方法\rArrayList\u0026lt;String\u0026gt; list1 = new ArrayList\u0026lt;\u0026gt;(); Collections.addAll(list1,\u0026#34;xiaohong,19\u0026#34;,\u0026#34;xiaoliu,19\u0026#34;,\u0026#34;xiaoli,18\u0026#34;,\u0026#34;xiaohan,18\u0026#34; ); list1.stream().map(new Function\u0026lt;String, Student\u0026gt;() { @Override public Student apply(String s) { return new Student(s); } }).forEach(s-\u0026gt; System.out.println(s)); public Student(String s) { String name = s.split(\u0026#34;,\u0026#34;)[0]; Integer age = Integer.valueOf(s.split(\u0026#34;,\u0026#34;)[1]); this.name = name; this.age = age; } 改为方法引用, 自动识别使用只含有一个参数的构造函数 ArrayList\u0026lt;String\u0026gt; list1 = new ArrayList\u0026lt;\u0026gt;(); Collections.addAll(list1,\u0026#34;xiaohong,19\u0026#34;,\u0026#34;xiaoliu,19\u0026#34;,\u0026#34;xiaoli,18\u0026#34;,\u0026#34;xiaohan,18\u0026#34; ); list1.stream().map(Student::new).forEach(s-\u0026gt; System.out.println(s)); 数组也可以引用 String[] array = list.stream().toArray(new IntFunction\u0026lt;String[]\u0026gt;() { @Override public String[] apply(int value) { // value 是流上的元素个数 return new String[value]; } }); String[] array = list.stream().toArray(String[]::new); 引用的参数问题\r如何引用没有参数的函数? 下面 toUpperCase() 没有参数传入, 但 apply () 需要 传 String, toUpperCase() 不是 static 修饰的, 所以 第一个参数(默认省略) 是 this, this 是 String 类型的, 符合 apply 的传参要求. 因为传入的方法没有参数, 因此流中的每个数据会直接调用该函数, 返回值还是返回值; 流的类型, 流元素的类型时 String 因此只能穿 String类下面的成员方法 返回值的类型, 一致即可 ","date":"2026-04-11T00:00:00Z","permalink":"/p/java-%E6%96%B9%E6%B3%95%E5%BC%95%E7%94%A8/","title":"Java 方法引用"},{"content":"Java 基本概念\r字面量\r特殊字面量\r'\\t' 打印时将前面的字符串长度使用空格补齐到8或8的倍数. 存储\r进制转换\r编码\r简单文件\r数值类型\r基本数据类型\rLong 类型定义时要加 L或l Float 类型定义时要加 F或f 数据类型转换\r隐式类型转换 强制 先从二进制截取 以补码的结构解析二进制码, 获取数据. 引用数据类型\r键盘录入\rpublic class Main { public static void main(String[] args) { System.out.println(\u0026#34;Hello and welcome!\u0026#34;); Scanner sc = new Scanner(System.in); String s = sc.nextLine(); System.out.println(s); } } 运算\r短路运算符\r提前返回结果以提高程序的运行效率 原码 反码 补码\r使用反码进行负数计算(不跨零)是正确的 补码是负数的反码向下错了一位, 多出来一个 -128 ","date":"2026-04-11T00:00:00Z","permalink":"/p/java-%E5%9F%BA%E6%9C%AC%E6%A6%82%E5%BF%B5/","title":"Java 基本概念"},{"content":"Java 面向对象\r规范\r一个代码文件可以有多个类, 但是只能有一个类是 public 修饰的. 构造\r由虚拟机调用构造方法 系统默认创建无参构造, 自定义有参或无参的构造方法后, 虚拟机不会创建无参构造. this\r对象的函数入栈时会有 this this 由虚拟机调用 进阶\rstatic\r静态变量 内存图 静态方法内存 总结\r继承\rjava 只支持单继承 属性继承关系\r默认 private 私有变量可以继承,但是不能使用 方法继承关系\r非 private static final 函数会被添加到虚方法表 虚方法表会层层向下给到子类 重写\r构造方法\rthis super\r多态\r弊端\rinstanceof\rfinal\r修饰变量时必须要赋值且只能赋值一次 修饰基本数据类型, 记录的值不能改变 修饰引用数据类型, 记录的地址值不能改变, 内部的属性值可以改变. 权限修饰符\r实际开发中, 一般使用 private public, 一般成员变量私有, 成员函数共有 class test{\rprivate int id;\rpublic test(int id) {\rthis.id = id;\r}\rpublic test(test test){\rid = test.id;\r}\r} 构造方法为什么可以方位 私有变量\r在 Java 中，**构造方法可以访问同类对象的私有变量**，这是因为 **Java 的访问控制是基于类（class）级别的，而不是基于对象（instance）级别的**。也就是说，**同一个类的不同对象之间可以互相访问私有成员**，包括私有字段（`private`）、私有方法等。 代码块\r构造代码块 静态代码块 抽象类和抽象方法\r抽象类不能实例化, 但是可以有构造方法; 因为子类在初始化时父类的私有变量初始化要使用该抽象类的构造方法. 接口\r接口是对行为的抽象 接口中可以有方法体, 在接口升级中可以防止报错; 接口中增加10个新函数, 添加 default 防止继承接口的类报错 接口多态\r接口适配器模式\r内部类\r成员内部类\r获取内部类对象\rOuter.Inner oi = new Outer().new Inner(); System.out.println(oi.name); Outer outer = new Outer(); Outer.Inner instance = outer.getInstance(); System.out.println(instance); //OOP.ineerClass.Outer$Inner@4e50df2e 如果内部类不是 private 修饰的可以使用第一种, 否则使用第二种. 内部类中使用 Outer.this.name 访问外部类的成员变量 静态内部类\r局部内部类\r匿名内部类\r匿名内部类的名字是 外部类$1.class, 外部类$2.class demo\rpublic class main { public static void main(String[] args) { method( new Swim(){ @Override public void swim() { System.out.println(\u0026#34;花式游泳\u0026#34;); } } ); } public static void method(Swim swim){ swim.swim(); } } Swim 是一个接口, 当需要一个有 swim() 方法的对象时可以使用匿名内部类 ","date":"2026-04-11T00:00:00Z","permalink":"/p/java-%E9%9D%A2%E5%90%91%E5%AF%B9%E8%B1%A1/","title":"Java 面向对象"},{"content":"Java 内存分配\rhttps://www.bilibili.com/video/BV17F411T7Ao?vd_source=a9a24992f7f570a16d5a331e8fed9f0d\u0026spm_id_from=333.788.player.switch\u0026p=87\n内存图\r一个对象\r两个对象\rnew 了两个对象, 堆空间就会分配两个空间. main 方法执行完后, 栈空间释放, 堆空间中的对象也会释放 ","date":"2026-04-11T00:00:00Z","permalink":"/p/java-%E5%86%85%E5%AD%98%E5%88%86%E9%85%8D/","title":"Java 内存分配"},{"content":"Java 数组\r基本概念\rint[] array; int array[]; 静态初始化 int[] array = {1,2,3,4} 动态初始化 int[] array = new int[4]; 默认初始化值: 整数:0 小数:0.0 字符数据类型:\u0026#39;/u0000\u0026#39; 布尔类型:false 引用数据类型:null String数组默认就是null,char是基本数据类型,String不是基本数据类型 内存图\rnew 关键字在堆中开辟空间 ","date":"2026-04-11T00:00:00Z","permalink":"/p/java-%E6%95%B0%E7%BB%84/","title":"Java 数组"},{"content":"Java 学习路线与知识图谱\r思维导图：https://www.processon.com/mindmap/679e21029a70b82af8a67914 参考文章：https://www.cnblogs.com/zhbeier/p/16463505.html 1. Java 基础（核心语言特性）\r语法基础（变量、数据类型、运算符、流程控制） 面向对象编程（封装、继承、多态、抽象、接口） 异常处理（try-catch-finally、异常类型） Java 泛型、Lambda 表达式、反射机制 多线程与并发（线程池、锁、volatile、CAS） JVM 知识（类加载机制、垃圾回收 GC、JIT 编译） 2. 数据结构与算法\r线性结构：数组、链表、栈、队列 非线性结构：树（二叉树、AVL 树、红黑树、B+树）、图 查找与排序（快速排序、归并排序、二分查找等） 常见算法（动态规划、贪心算法、回溯、分治等） Java 中的数据结构（ArrayList、LinkedList、HashMap、ConcurrentHashMap、TreeSet 等） 3. 并发与多线程编程（可独立成一个大类）\r线程基础（Thread、Runnable、Callable） 并发工具类（CountDownLatch、Semaphore、CyclicBarrier 等） 线程池（Executor、ForkJoin、ScheduledThreadPool） 并发编程模型（ReentrantLock、AQS、CAS、Unsafe） 4. 网络编程\rJava 网络通信基础（TCP/UDP） Socket 编程（BIO、NIO、AIO） Http/Https 协议、WebSocket Netty 框架（高性能网络通信） 5. Java 企业级开发框架（主流框架）\rSpring 生态（Spring Boot、Spring MVC、Spring Cloud、Spring Security） ORM 框架（MyBatis、Hibernate、JPA） 微服务架构（Spring Cloud、Dubbo、gRPC） 数据库技术（MySQL、Redis、MongoDB、Elasticsearch） 分布式技术（Zookeeper、Kafka、RabbitMQ、RocketMQ） 6. Java 工具 \u0026amp; DevOps\r构建工具（Maven、Gradle） 版本控制（Git、SVN） 容器化与部署（Docker、Kubernetes） 日志系统（Log4j、SLF4J、ELK） 性能优化（JVM 调优、JProfiler、JConsole） CI/CD（Jenkins、GitLab CI/CD） 7. 其他知识（扩展）\r设计模式（单例、工厂、代理、策略、责任链等） 计算机基础（操作系统、计算机网络、数据库系统原理） 安全（加密解密、JWT、OAuth2） Java 未来趋势（Quarkus、GraalVM、Serverless） ","date":"2026-04-11T00:00:00Z","permalink":"/p/java-%E5%AD%A6%E4%B9%A0%E8%B7%AF%E7%BA%BF%E4%B8%8E%E7%9F%A5%E8%AF%86%E5%9B%BE%E8%B0%B1/","title":"Java 学习路线与知识图谱"},{"content":"Java 异常处理\r编译时异常必须要在代码中手动处理, 否则报错. 在于提醒程序员提前解决 异常处理\rJvm 默认会输出到控制台, 程序不会往下执行 自己处理 父类要写在下面 异常函数\r交给调用者处理 自定义异常\r","date":"2026-04-11T00:00:00Z","permalink":"/p/java-%E5%BC%82%E5%B8%B8%E5%A4%84%E7%90%86/","title":"Java 异常处理"},{"content":"Java 语言特性\r跨平台\rc 不是跨平台的 py 等编译一条执行一条 java 实际是通过虚拟机(中间件)实现跨平台的. Jdk Jre\rjre 排除了开发工具中的一部分工具, 留下了运行需要的工具. jdk 包含了 jre,jre 包含了 jvm ","date":"2026-04-11T00:00:00Z","permalink":"/p/java-%E8%AF%AD%E8%A8%80%E7%89%B9%E6%80%A7/","title":"Java 语言特性"},{"content":"Java 字符串\r字符串一旦创建不能被修改, 只会赋值个新的字符串. 创建\r字符串要发生变化时, 可以使用数组构造的方式. byte 网络中传输的数据都是字节型的, byte[] bytes = {97,98,99}; String s = new String(bytes, Charset.forName(\u0026#34;UTF-8\u0026#34;)); String s = new String(bytes, StandardCharsets.UTF_8); 内存串池\r直接赋值\r串池在堆 new\r字符串比较\r==\r== 示例\r比较值\rs1.equals(s2) StringBuilder\r提高字符串的操作效率, 传统 String 拼接中会产生很多无用的字符串 append() 添加任意内容 reverse() length() toString() StringJoiner\r方法 add() length() toString() 原理\rjdk8之前 jdk8之后 示例\r没有变量参与就会直接拼接 源码分析\r","date":"2026-04-11T00:00:00Z","permalink":"/p/java-%E5%AD%97%E7%AC%A6%E4%B8%B2/","title":"Java 字符串"},{"content":"Java 字节与字节数组\rlog.info(Arrays.toString(\u0026#34;一\u0026#34;.getBytes())); [-28, -72, -128] 是 UTF-8 编码下字节 e4 b8 80 的有符号十进制表示。\nbyte[] bytes = \u0026#34;一\u0026#34;.getBytes(); for (byte b : bytes) { System.out.printf(\u0026#34;%02x \u0026#34;, b \u0026amp; 0xFF); // 转为无符号 } ","date":"2026-04-11T00:00:00Z","permalink":"/p/java-%E5%AD%97%E8%8A%82%E4%B8%8E%E5%AD%97%E8%8A%82%E6%95%B0%E7%BB%84/","title":"Java 字节与字节数组"},{"content":"JVM 类加载、字节码技术与内存模型\r四、类加载与字节码技术\r1、类文件结构\r通过 javac 类名.java 编译 java 文件后，会生成一个 .class 的文件！ 以下是字节码文件：\n0000000 ca fe ba be 00 00 00 34 00 23 0a 00 06 00 15 09 0000020 00 16 00 17 08 00 18 0a 00 19 00 1a 07 00 1b 07 0000040 00 1c 01 00 06 3c 69 6e 69 74 3e 01 00 03 28 29 0000060 56 01 00 04 43 6f 64 65 01 00 0f 4c 69 6e 65 4e 0000100 75 6d 62 65 72 54 61 62 6c 65 01 00 12 4c 6f 63 0000120 61 6c 56 61 72 69 61 62 6c 65 54 61 62 6c 65 01 0000140 00 04 74 68 69 73 01 00 1d 4c 63 6e 2f 69 74 63 0000160 61 73 74 2f 6a 76 6d 2f 74 35 2f 48 65 6c 6c 6f 0000200 57 6f 72 6c 64 3b 01 00 04 6d 61 69 6e 01 00 16 0000220 28 5b 4c 6a 61 76 61 2f 6c 61 6e 67 2f 53 74 72 0000240 69 6e 67 3b 29 56 01 00 04 61 72 67 73 01 00 13 0000260 5b 4c 6a 61 76 61 2f 6c 61 6e 67 2f 53 74 72 69 0000300 6e 67 3b 01 00 10 4d 65 74 68 6f 64 50 61 72 61 0000320 6d 65 74 65 72 73 01 00 0a 53 6f 75 72 63 65 46 0000340 69 6c 65 01 00 0f 48 65 6c 6c 6f 57 6f 72 6c 64 0000360 2e 6a 61 76 61 0c 00 07 00 08 07 00 1d 0c 00 1e 0000400 00 1f 01 00 0b 68 65 6c 6c 6f 20 77 6f 72 6c 64 0000420 07 00 20 0c 00 21 00 22 01 00 1b 63 6e 2f 69 74 0000440 63 61 73 74 2f 6a 76 6d 2f 74 35 2f 48 65 6c 6c 0000460 6f 57 6f 72 6c 64 01 00 10 6a 61 76 61 2f 6c 61 0000500 6e 67 2f 4f 62 6a 65 63 74 01 00 10 6a 61 76 61 0000520 2f 6c 61 6e 67 2f 53 79 73 74 65 6d 01 00 03 6f 0000540 75 74 01 00 15 4c 6a 61 76 61 2f 69 6f 2f 50 72 0000560 69 6e 74 53 74 72 65 61 6d 3b 01 00 13 6a 61 76 0000600 61 2f 69 6f 2f 50 72 69 6e 74 53 74 72 65 61 6d 0000620 01 00 07 70 72 69 6e 74 6c 6e 01 00 15 28 4c 6a 0000640 61 76 61 2f 6c 61 6e 67 2f 53 74 72 69 6e 67 3b 0000660 29 56 00 21 00 05 00 06 00 00 00 00 00 02 00 01 0000700 00 07 00 08 00 01 00 09 00 00 00 2f 00 01 00 01 0000720 00 00 00 05 2a b7 00 01 b1 00 00 00 02 00 0a 00 0000740 00 00 06 00 01 00 00 00 04 00 0b 00 00 00 0c 00 0000760 01 00 00 00 05 00 0c 00 0d 00 00 00 09 00 0e 00 0001000 0f 00 02 00 09 00 00 00 37 00 02 00 01 00 00 00 0001020 09 b2 00 02 12 03 b6 00 04 b1 00 00 00 02 00 0a 0001040 00 00 00 0a 00 02 00 00 00 06 00 08 00 07 00 0b 0001060 00 00 00 0c 00 01 00 00 00 09 00 10 00 11 00 00 0001100 00 12 00 00 00 05 01 00 10 00 00 00 01 00 13 00 0001120 00 00 02 00 14 根据 JVM 规范，类文件结构如下：\nu4 magic u2 minor_version; u2 major_version; u2 constant_pool_count; cp_info constant_pool[constant_pool_count-1]; u2 access_flags; u2 this_class; u2 super_class; u2 interfaces_count; u2 interfaces[interfaces_count]; u2 fields_count; field_info fields[fields_count]; u2 methods_count; method_info methods[methods_count]; u2 attributes_count; attribute_info attributes[attributes_count]; 1）魔数\ru4 magic 对应字节码文件的 0~3 个字节 0000000 ca fe ba be 00 00 00 34 00 23 0a 00 06 00 15 09 ca fe ba be ：意思是 .class 文件，不同的东西有不同的魔数，比如 jpg、png 图片等！\n2）版本\ru2 minor_version; u2 major_version; 0000000 ca fe ba be 00 00 00 34 00 23 0a 00 06 00 15 09 00 00 00 34：34H（16进制） = 52（10进制），代表JDK8\n3）常量池\r… 参考文档 传送门\n2、字节码指令\r可参考： 字节码指令\n1）javap 工具\rJava 中提供了 javap 工具来反编译 class 文件\njavap -v D:Demo.class 2）图解方法执行流程\r代码\npublic class Demo3_1 { public static void main(String[] args) { int a = 10; int b = Short.MAX_VALUE + 1; int c = a + b; System.out.println(c); } } 常量池载入运行时常量池 常量池也属于方法区，只不过这里单独提出来了 方法字节码载入方法区 （stack=2，locals=4） 对应操作数栈有 2 个空间（每个空间 4 个字节），局部变量表中有 4 个槽位。 执行引擎开始执行字节码 bipush 10\n将一个 byte 压入操作数栈（其长度会补齐 4 个字节），类似的指令还有 sipush 将一个 short 压入操作数栈（其长度会补齐 4 个字节） ldc 将一个 int 压入操作数栈 ldc2_w 将一个 long 压入操作数栈（分两次压入，因为 long 是 8 个字节） 这里小的数字都是和字节码指令存在一起，超过 short 范围的数字存入了常量池 istore 1 将操作数栈栈顶元素弹出，放入局部变量表的 slot 1 中 对应代码中的 a = 10 ldc #3 读取运行时常量池中 #3 ，即 32768 (超过 short 最大值范围的数会被放到运行时常量池中)，将其加载到操作数栈中 注意 Short.MAX_VALUE 是 32767，所以 32768 = Short.MAX_VALUE + 1 实际是在编译期间计算好的。 istore 2 将操作数栈中的元素弹出，放到局部变量表的 2 号位置 iload1 iload2 将局部变量表中 1 号位置和 2 号位置的元素放入操作数栈中。因为只能在操作数栈中执行运算操作 iadd 将操作数栈中的两个元素弹出栈并相加，结果在压入操作数栈中。 istore 3 将操作数栈中的元素弹出，放入局部变量表的3号位置。 getstatic #4 在运行时常量池中找到 #4 ，发现是一个对象，在堆内存中找到该对象，并将其引用放入操作数栈中 iload 3 将局部变量表中 3 号位置的元素压入操作数栈中。 invokevirtual #5 找到常量池 #5 项，定位到方法区 java/io/PrintStream.println:(I)V 方法 生成新的栈帧（分配 locals、stack等） 传递参数，执行新栈帧中的字节码 执行完毕，弹出栈帧 清除 main 操作数栈内容 return 完成 main 方法调用，弹出 main 栈帧，程序结束\n3）通过字节码指令分析问题\r代码\npublic class Code_11_ByteCodeTest { public static void main(String[] args) { int i = 0; int x = 0; while (i \u0026lt; 10) { x = x++; i++; } System.out.println(x); // 0 } } 为什么最终的 x 结果为 0 呢？ 通过分析字节码指令即可知晓\nCode: stack=2, locals=3, args_size=1\t// 操作数栈分配2个空间，局部变量表分配 3 个空间 0: iconst_0\t// 准备一个常数 0 1: istore_1\t// 将常数 0 放入局部变量表的 1 号槽位 i = 0 2: iconst_0\t// 准备一个常数 0 3: istore_2\t// 将常数 0 放入局部变量的 2 号槽位 x = 0\t4: iload_1\t// 将局部变量表 1 号槽位的数放入操作数栈中 5: bipush 10\t// 将数字 10 放入操作数栈中，此时操作数栈中有 2 个数 7: if_icmpge 21\t// 比较操作数栈中的两个数，如果下面的数大于上面的数，就跳转到 21 。这里的比较是将两个数做减法。因为涉及运算操作，所以会将两个数弹出操作数栈来进行运算。运算结束后操作数栈为空 10: iload_2\t// 将局部变量 2 号槽位的数放入操作数栈中，放入的值是 0 11: iinc 2, 1\t// 将局部变量 2 号槽位的数加 1 ，自增后，槽位中的值为 1 14: istore_2\t//将操作数栈中的数放入到局部变量表的 2 号槽位，2 号槽位的值又变为了0 15: iinc 1, 1 // 1 号槽位的值自增 1 18: goto 4 // 跳转到第4条指令 21: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream; 24: iload_2 25: invokevirtual #3 // Method java/io/PrintStream.println:(I)V 28: return 4）构造方法\rcinit()V\npublic class Code_12_CinitTest { static int i = 10; static { i = 20; } static { i = 30; } public static void main(String[] args) { System.out.println(i); // 30 } } 编译器会按从上至下的顺序，收集所有 static 静态代码块和静态成员赋值的代码，合并为一个特殊的方法 cinit()V ：\nstack=1, locals=0, args_size=0 0: bipush 10 2: putstatic #3 // Field i:I 5: bipush 20 7: putstatic #3 // Field i:I 10: bipush 30 12: putstatic #3 // Field i:I 15: return init()V\npublic class Code_13_InitTest { private String a = \u0026#34;s1\u0026#34;; { b = 20; } private int b = 10; { a = \u0026#34;s2\u0026#34;; } public Code_13_InitTest(String a, int b) { this.a = a; this.b = b; } public static void main(String[] args) { Code_13_InitTest d = new Code_13_InitTest(\u0026#34;s3\u0026#34;, 30); System.out.println(d.a); System.out.println(d.b); } } 编译器会按从上至下的顺序，收集所有 {} 代码块和成员变量赋值的代码，形成新的构造方法，但原始构造方法内的代码总是在后.\nCode: stack=2, locals=3, args_size=3 0: aload_0 1: invokespecial #1 // Method java/lang/Object.\u0026#34;\u0026lt;init\u0026gt;\u0026#34;:()V 4: aload_0 5: ldc #2 // String s1 7: putfield #3 // Field a:Ljava/lang/String; 10: aload_0 11: bipush 20 13: putfield #4 // Field b:I 16: aload_0 17: bipush 10 19: putfield #4 // Field b:I 22: aload_0 23: ldc #5 // String s2 25: putfield #3 // Field a:Ljava/lang/String; // 原始构造方法在最后执行 28: aload_0 29: aload_1 30: putfield #3 // Field a:Ljava/lang/String; 33: aload_0 34: iload_2 35: putfield #4 // Field b:I 38: return 5）方法调用\rpublic class Code_14_MethodTest { public Code_14_MethodTest() { } private void test1() { } private final void test2() { } public void test3() { } public static void test4() { } public static void main(String[] args) { Code_14_MethodTest obj = new Code_14_MethodTest(); obj.test1(); obj.test2(); obj.test3(); Code_14_MethodTest.test4(); } } 不同方法在调用时，对应的虚拟机指令有所区别\n私有、构造、被 final 修饰的方法，在调用时都使用 invokespecial 指令 普通成员方法在调用时，使用 invokevirtual 指令。因为编译期间无法确定该方法的内容，只有在运行期间才能确定 静态方法在调用时使用 invokestatic 指令 Code: stack=2, locals=2, args_size=1 0: new #2 // 3: dup // 复制一份对象地址压入操作数栈中 4: invokespecial #3 // Method \u0026#34;\u0026lt;init\u0026gt;\u0026#34;:()V 7: astore_1 8: aload_1 9: invokespecial #4 // Method test1:()V 12: aload_1 13: invokespecial #5 // Method test2:()V 16: aload_1 17: invokevirtual #6 // Method test3:()V 20: invokestatic #7 // Method test4:()V 23: return new 是创建【对象】，给对象分配堆内存，执行成功会将【对象引用】压入操作数栈 dup 是赋值操作数栈栈顶的内容，本例即为【对象引用】，为什么需要两份引用呢，一个是要配合 invokespecial 调用该对象的构造方法 “init”: ()V （会消耗掉栈顶一个引用），另一个要 配合 astore_1 赋值给局部变量 终方法（ﬁnal），私有方法（private），构造方法都是由 invokespecial 指令来调用，属于静态绑定 普通成员方法是由 invokevirtual 调用，属于动态绑定，即支持多态 成员方法与静态方法调用的另一个区别是，执行方法前是否需要【对象引用】 6）多态原理\r因为普通成员方法需要在运行时才能确定具体的内容，所以虚拟机需要调用 invokevirtual 指令 在执行 invokevirtual 指令时，经历了以下几个步骤\n先通过栈帧中对象的引用找到对象 分析对象头，找到对象实际的 Class Class 结构中有 vtable 查询 vtable 找到方法的具体地址 执行方法的字节码 7）异常处理\rtry-catch\npublic class Code_15_TryCatchTest { public static void main(String[] args) { int i = 0; try { i = 10; }catch (Exception e) { i = 20; } } } 对应字节码指令\nCode: stack=1, locals=3, args_size=1 0: iconst_0 1: istore_1 2: bipush 10 4: istore_1 5: goto 12 8: astore_2 9: bipush 20 11: istore_1 12: return //多出来一个异常表 Exception table: from to target type 2 5 8 Class java/lang/Exception 可以看到多出来一个 Exception table 的结构，[from, to) 是前闭后开（也就是检测 2~4 行）的检测范围，一旦这个范围内的字节码执行出现异常，则通过 type 匹配异常类型，如果一致，进入 target 所指示行号 8 行的字节码指令 astore_2 是将异常对象引用存入局部变量表的 2 号位置（为 e ） 多个 single-catch\npublic class Code_16_MultipleCatchTest { public static void main(String[] args) { int i = 0; try { i = 10; }catch (ArithmeticException e) { i = 20; }catch (Exception e) { i = 30; } } } 对应的字节码\nCode: stack=1, locals=3, args_size=1 0: iconst_0 1: istore_1 2: bipush 10 4: istore_1 5: goto 19 8: astore_2 9: bipush 20 11: istore_1 12: goto 19 15: astore_2 16: bipush 30 18: istore_1 19: return Exception table: from to target type 2 5 8 Class java/lang/ArithmeticException 2 5 15 Class java/lang/Exception 因为异常出现时，只能进入 Exception table 中一个分支，所以局部变量表 slot 2 位置被共用 finally\npublic class Code_17_FinallyTest { public static void main(String[] args) { int i = 0; try { i = 10; } catch (Exception e) { i = 20; } finally { i = 30; } } } 对应字节码\nCode: stack=1, locals=4, args_size=1 0: iconst_0 1: istore_1 // try块 2: bipush 10 4: istore_1 // try块执行完后，会执行finally 5: bipush 30 7: istore_1 8: goto 27 // catch块 11: astore_2 // 异常信息放入局部变量表的2号槽位 12: bipush 20 14: istore_1 // catch块执行完后，会执行finally 15: bipush 30 17: istore_1 18: goto 27 // 出现异常，但未被 Exception 捕获，会抛出其他异常，这时也需要执行 finally 块中的代码 21: astore_3 22: bipush 30 24: istore_1 25: aload_3 26: athrow // 抛出异常 27: return Exception table: from to target type 2 5 11 Class java/lang/Exception 2 5 21 any 11 15 21 any 可以看到 ﬁnally 中的代码被复制了 3 份，分别放入 try 流程，catch 流程以及 catch 剩余的异常类型流程 注意：虽然从字节码指令看来，每个块中都有 finally 块，但是 finally 块中的代码只会被执行一次\nfinally 中的 return\npublic class Code_18_FinallyReturnTest { public static void main(String[] args) { int i = Code_18_FinallyReturnTest.test(); // 结果为 20 System.out.println(i); } public static int test() { int i; try { i = 10; return i; } finally { i = 20; return i; } } } 对应字节码\nCode: stack=1, locals=3, args_size=0 0: bipush 10 2: istore_0 3: iload_0 4: istore_1 // 暂存返回值 5: bipush 20 7: istore_0 8: iload_0 9: ireturn\t// ireturn 会返回操作数栈顶的整型值 20 // 如果出现异常，还是会执行finally 块中的内容，没有抛出异常 10: astore_2 11: bipush 20 13: istore_0 14: iload_0 15: ireturn\t// 这里没有 athrow 了，也就是如果在 finally 块中如果有返回操作的话，且 try 块中出现异常，会吞掉异常！ Exception table: from to target type 0 5 10 any 由于 ﬁnally 中的 ireturn 被插入了所有可能的流程，因此返回结果肯定以ﬁnally的为准 至于字节码中第 2 行，似乎没啥用，且留个伏笔，看下个例子 跟上例中的 ﬁnally 相比，发现没有 athrow 了，这告诉我们：如果在 ﬁnally 中出现了 return，会吞掉异常 所以不要在finally中进行返回操作 被吞掉的异常\npublic static int test() { int i; try { i = 10; // 这里应该会抛出异常 i = i/0; return i; } finally { i = 20; return i; } } 会发现打印结果为 20 ，并未抛出异常\nfinally 不带 return\npublic static int test() { int i = 10; try { return i; } finally { i = 20; } } 对应字节码\nCode: stack=1, locals=3, args_size=0 0: bipush 10 2: istore_0 // 赋值给i 10 3: iload_0\t// 加载到操作数栈顶 4: istore_1 // 加载到局部变量表的1号位置 5: bipush 20 7: istore_0 // 赋值给i 20 8: iload_1 // 加载局部变量表1号位置的数10到操作数栈 9: ireturn // 返回操作数栈顶元素 10 10: astore_2 11: bipush 20 13: istore_0 14: aload_2 // 加载异常 15: athrow // 抛出异常 Exception table: from to target type 3 5 10 any 8）Synchronized\rpublic class Code_19_SyncTest { public static void main(String[] args) { Object lock = new Object(); synchronized (lock) { System.out.println(\u0026#34;ok\u0026#34;); } } } 对应字节码\nCode: stack=2, locals=4, args_size=1 0: new #2 // class java/lang/Object 3: dup // 复制一份栈顶，然后压入栈中。用于函数消耗 4: invokespecial #1 // Method java/lang/Object.\u0026#34;\u0026lt;init\u0026gt;\u0026#34;:()V 7: astore_1 // 将栈顶的对象地址方法 局部变量表中 1 中 8: aload_1 // 加载到操作数栈 9: dup // 复制一份，放到操作数栈，用于加锁时消耗 10: astore_2 // 将操作数栈顶元素弹出，暂存到局部变量表的 2 号槽位。这时操作数栈中有一份对象的引用 11: monitorenter // 加锁 12: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream; 15: ldc #4 // String ok 17: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 20: aload_2 // 加载对象到栈顶 21: monitorexit // 释放锁 22: goto 30 // 异常情况的解决方案 释放锁！ 25: astore_3 26: aload_2 27: monitorexit 28: aload_3 29: athrow 30: return // 异常表！ Exception table: from to target type 12 22 25 any 25 28 25 any 3、编译期处理\r所谓的 语法糖 ，其实就是指 java 编译器把 .java 源码编译为 .class 字节码的过程中，自动生成和转换的一些代码，主要是为了减轻程序员的负担，算是 java 编译器给我们的一个额外福利 注意，以下代码的分析，借助了 javap 工具，idea 的反编译功能，idea 插件 jclasslib 等工具。另外， 编译器转换的结果直接就是 class 字节码，只是为了便于阅读，给出了 几乎等价 的 java 源码方式，并不是编译器还会转换出中间的 java 源码，切记。\n1）默认构造器\rpublic class Candy1 { } 经过编译期优化后\npublic class Candy1 { // 这个无参构造器是java编译器帮我们加上的 public Candy1() { // 即调用父类 Object 的无参构造方法，即调用 java/lang/Object.\u0026#34; \u0026lt;init\u0026gt;\u0026#34;:()V super(); } } 2）自动拆装箱\r基本类型和其包装类型的相互转换过程，称为拆装箱 在 JDK 5 以后，它们的转换可以在编译期自动完成\npublic class Candy2 { public static void main(String[] args) { Integer x = 1; int y = x; } } 转换过程如下\npublic class Candy2 { public static void main(String[] args) { // 基本类型赋值给包装类型，称为装箱 Integer x = Integer.valueOf(1); // 包装类型赋值给基本类型，称谓拆箱 int y = x.intValue(); } } 3）泛型集合取值\r泛型也是在 JDK 5 开始加入的特性，但 java 在编译泛型代码后会执行泛型擦除的动作，即泛型信息在编译为字节码之后就丢失了，实际的类型都当做了 Object 类型来处理：\npublic class Candy3 { public static void main(String[] args) { List\u0026lt;Integer\u0026gt; list = new ArrayList\u0026lt;\u0026gt;(); list.add(10); Integer x = list.get(0); } } 对应字节码\nCode: stack=2, locals=3, args_size=1 0: new #2 // class java/util/ArrayList 3: dup 4: invokespecial #3 // Method java/util/ArrayList.\u0026#34;\u0026lt;init\u0026gt;\u0026#34;:()V 7: astore_1 8: aload_1 9: bipush 10 11: invokestatic #4 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer; // 这里进行了泛型擦除，实际调用的是add(Objcet o) 14: invokeinterface #5, 2 // InterfaceMethod java/util/List.add:(Ljava/lang/Object;)Z 19: pop 20: aload_1 21: iconst_0 // 这里也进行了泛型擦除，实际调用的是get(Object o) 22: invokeinterface #6, 2 // InterfaceMethod java/util/List.get:(I)Ljava/lang/Object; // 这里进行了类型转换，将 Object 转换成了 Integer 27: checkcast #7 // class java/lang/Integer 30: astore_2 31: return 所以调用 get 函数取值时，有一个类型转换的操作。\nInteger x = (Integer) list.get(0); 如果要将返回结果赋值给一个 int 类型的变量，则还有自动拆箱的操作\nint x = (Integer) list.get(0).intValue(); 使用反射可以得到，参数的类型以及泛型类型。泛型反射代码如下：\npublic static void main(String[] args) throws NoSuchMethodException { // 1. 拿到方法 Method method = Code_20_ReflectTest.class.getMethod(\u0026#34;test\u0026#34;, List.class, Map.class); // 2. 得到泛型参数的类型信息 Type[] types = method.getGenericParameterTypes(); for(Type type : types) { // 3. 判断参数类型是否，带泛型的类型。 if(type instanceof ParameterizedType) { ParameterizedType parameterizedType = (ParameterizedType) type; // 4. 得到原始类型 System.out.println(\u0026#34;原始类型 - \u0026#34; + parameterizedType.getRawType()); // 5. 拿到泛型类型 Type[] arguments = parameterizedType.getActualTypeArguments(); for(int i = 0; i \u0026lt; arguments.length; i++) { System.out.printf(\u0026#34;泛型参数[%d] - %s\\n\u0026#34;, i, arguments[i]); } } } } public Set\u0026lt;Integer\u0026gt; test(List\u0026lt;String\u0026gt; list, Map\u0026lt;Integer, Object\u0026gt; map) { return null; } 输出：\n原始类型 - interface java.util.List 泛型参数[0] - class java.lang.String 原始类型 - interface java.util.Map 泛型参数[0] - class java.lang.Integer 泛型参数[1] - class java.lang.Object 4）可变参数\r可变参数也是 JDK 5 开始加入的新特性： 例如：\npublic class Candy4 { public static void foo(String... args) { // 将 args 赋值给 arr ，可以看出 String... 实际就是 String[] String[] arr = args; System.out.println(arr.length); } public static void main(String[] args) { foo(\u0026#34;hello\u0026#34;, \u0026#34;world\u0026#34;); } } 可变参数 String… args 其实是一个 String[] args ，从代码中的赋值语句中就可以看出来。 同 样 java 编译器会在编译期间将上述代码变换为：\npublic class Candy4 { public Candy4 {} public static void foo(String[] args) { String[] arr = args; System.out.println(arr.length); } public static void main(String[] args) { foo(new String[]); } } 注意，如果调用的是 foo() ，即未传递参数时，等价代码为 foo(new String[]{}) ，创建了一个空数组，而不是直接传递的 null .\n5）foreach 循环\r仍是 JDK 5 开始引入的语法糖，数组的循环：\npublic class Candy5 { public static void main(String[] args) { // 数组赋初值的简化写法也是一种语法糖。 int[] arr = {1, 2, 3, 4, 5}; for(int x : arr) { System.out.println(x); } } } 编译器会帮我们转换为\npublic class Candy5 { public Candy5() {} public static void main(String[] args) { int[] arr = new int[]{1, 2, 3, 4, 5}; for(int i = 0; i \u0026lt; arr.length; ++i) { int x = arr[i]; System.out.println(x); } } } 如果是集合使用 foreach\npublic class Candy5 { public static void main(String[] args) { List\u0026lt;Integer\u0026gt; list = Arrays.asList(1, 2, 3, 4, 5); for (Integer x : list) { System.out.println(x); } } 集合要使用 foreach ，需要该集合类实现了 Iterable 接口，因为集合的遍历需要用到迭代器 Iterator.\npublic class Candy5 { public Candy5(){} public static void main(String[] args) { List\u0026lt;Integer\u0026gt; list = Arrays.asList(1, 2, 3, 4, 5); // 获得该集合的迭代器 Iterator\u0026lt;Integer\u0026gt; iterator = list.iterator(); while(iterator.hasNext()) { Integer x = iterator.next(); System.out.println(x); } } } 6）switch 字符串\r从 JDK 7 开始，switch 可以作用于字符串和枚举类，这个功能其实也是语法糖，例如：\npublic class Cnady6 { public static void main(String[] args) { String str = \u0026#34;hello\u0026#34;; switch (str) { case \u0026#34;hello\u0026#34; : System.out.println(\u0026#34;h\u0026#34;); break; case \u0026#34;world\u0026#34; : System.out.println(\u0026#34;w\u0026#34;); break; default: break; } } } 在编译器中执行的操作\npublic class Candy6 { public Candy6() { } public static void main(String[] args) { String str = \u0026#34;hello\u0026#34;; int x = -1; // 通过字符串的 hashCode + value 来判断是否匹配 switch (str.hashCode()) { // hello 的 hashCode case 99162322 : // 再次比较，因为字符串的 hashCode 有可能相等 if(str.equals(\u0026#34;hello\u0026#34;)) { x = 0; } break; // world 的 hashCode case 11331880 : if(str.equals(\u0026#34;world\u0026#34;)) { x = 1; } break; default: break; } // 用第二个 switch 在进行输出判断 switch (x) { case 0: System.out.println(\u0026#34;h\u0026#34;); break; case 1: System.out.println(\u0026#34;w\u0026#34;); break; default: break; } } } 过程说明：\n在编译期间，单个的 switch 被分为了两个 第一个用来匹配字符串，并给 x 赋值 字符串的匹配用到了字符串的 hashCode ，还用到了 equals 方法 使用 hashCode 是为了提高比较效率，使用 equals 是防止有 hashCode 冲突（如 BM 和 C .） 第二个用来根据x的值来决定输出语句 7）switch 枚举\renum SEX { MALE, FEMALE; } public class Candy7 { public static void main(String[] args) { SEX sex = SEX.MALE; switch (sex) { case MALE: System.out.println(\u0026#34;man\u0026#34;); break; case FEMALE: System.out.println(\u0026#34;woman\u0026#34;); break; default: break; } } } 编译器中执行的代码如下\nenum SEX { MALE, FEMALE; } public class Candy7 { /** * 定义一个合成类（仅 jvm 使用，对我们不可见） * 用来映射枚举的 ordinal 与数组元素的关系 * 枚举的 ordinal 表示枚举对象的序号，从 0 开始 * 即 MALE 的 ordinal()=0，FEMALE 的 ordinal()=1 */ static class $MAP { // 数组大小即为枚举元素个数，里面存放了 case 用于比较的数字 static int[] map = new int[2]; static { // ordinal 即枚举元素对应所在的位置，MALE 为 0 ，FEMALE 为 1 map[SEX.MALE.ordinal()] = 1; map[SEX.FEMALE.ordinal()] = 2; } } public static void main(String[] args) { SEX sex = SEX.MALE; // 将对应位置枚举元素的值赋给 x ，用于 case 操作 int x = $MAP.map[sex.ordinal()]; switch (x) { case 1: System.out.println(\u0026#34;man\u0026#34;); break; case 2: System.out.println(\u0026#34;woman\u0026#34;); break; default: break; } } } 8）枚举类\rJDK 7 新增了枚举类，以前面的性别枚举为例：\nenum SEX { MALE, FEMALE; } 转换后的代码\npublic final class Sex extends Enum\u0026lt;Sex\u0026gt; { // 对应枚举类中的元素 public static final Sex MALE; public static final Sex FEMALE; private static final Sex[] $VALUES; static { // 调用构造函数，传入枚举元素的值及 ordinal MALE = new Sex(\u0026#34;MALE\u0026#34;, 0); FEMALE = new Sex(\u0026#34;FEMALE\u0026#34;, 1); $VALUES = new Sex[]{MALE, FEMALE}; } // 调用父类中的方法 private Sex(String name, int ordinal) { super(name, ordinal); } public static Sex[] values() { return $VALUES.clone(); } public static Sex valueOf(String name) { return Enum.valueOf(Sex.class, name); } } 9）try-with-resources\rJDK 7 开始新增了对需要关闭的资源处理的特殊语法，‘try-with-resources’\ntry(资源变量 = 创建资源对象) { } catch() { } 其中资源对象需要实现 AutoCloseable 接口，例如 InputStream 、 OutputStream 、 Connection 、 Statement 、 ResultSet 等接口都实现了 AutoCloseable ，使用 try-with- resources 可以不用写 finally 语句块，编译器会帮助生成关闭资源代码，例如：\npublic class Candy9 { public static void main(String[] args) { try(InputStream is = new FileInputStream(\u0026#34;d:\\\\1.txt\u0026#34;)){\tSystem.out.println(is); } catch (IOException e) { e.printStackTrace(); } } } 会被转换为：\npublic class Candy9 { public Candy9() { } public static void main(String[] args) { try { InputStream is = new FileInputStream(\u0026#34;d:\\\\1.txt\u0026#34;); Throwable t = null; try { System.out.println(is); } catch (Throwable e1) { // t 是我们代码出现的异常 t = e1; throw e1; } finally { // 判断了资源不为空 if (is != null) { // 如果我们代码有异常 if (t != null) { try { is.close(); } catch (Throwable e2) { // 如果 close 出现异常，作为被压制异常添加 t.addSuppressed(e2); } } else { // 如果我们代码没有异常，close 出现的异常就是最后 catch 块中的 e is.close(); } } } } catch (IOException e) { e.printStackTrace(); } } } 为什么要设计一个 addSuppressed(Throwable e) （添加被压制异常）的方法呢？是为了防止异常信息的丢失（想想 try-with-resources 生成的 fianlly 中如果抛出了异常）：\npublic class Test6 { public static void main(String[] args) { try (MyResource resource = new MyResource()) { int i = 1/0; } catch (Exception e) { e.printStackTrace(); } } } class MyResource implements AutoCloseable { public void close() throws Exception { throw new Exception(\u0026#34;close 异常\u0026#34;); } } 输出：\njava.lang.ArithmeticException: / by zero at test.Test6.main(Test6.java:7) Suppressed: java.lang.Exception: close 异常 at test.MyResource.close(Test6.java:18) at test.Test6.main(Test6.java:6) 10）方法重写时的桥接方法\r我们都知道，方法重写时对返回值分两种情况： - 父子类的返回值完全一致 - 子类返回值可以是父类返回值的子类（比较绕口，见下面的例子）\nclass A { public Number m() { return 1; } } class B extends A { @Override // 子类 m 方法的返回值是 Integer 是父类 m 方法返回值 Number 的子类 public Integer m() { return 2; } } 对于子类，java 编译器会做如下处理：\nclass B extends A { public Integer m() { return 2; } // 此方法才是真正重写了父类 public Number m() 方法 public synthetic bridge Number m() { // 调用 public Integer m() return m(); } } 其中桥接方法比较特殊，仅对 java 虚拟机可见，并且与原来的 public Integer m() 没有命名冲突，可以 用下面反射代码来验证：\npublic static void main(String[] args) { for(Method m : B.class.getDeclaredMethods()) { System.out.println(m); } } 结果：\npublic java.lang.Integer cn.ali.jvm.test.B.m() public java.lang.Number cn.ali.jvm.test.B.m() 11）匿名内部类\rpublic class Candy10 { public static void main(String[] args) { Runnable runnable = new Runnable() { @Override public void run() { System.out.println(\u0026#34;running...\u0026#34;); } }; } } 转换后的代码\npublic class Candy10 { public static void main(String[] args) { // 用额外创建的类来创建匿名内部类对象 Runnable runnable = new Candy10$1(); } } // 创建了一个额外的类，实现了 Runnable 接口 final class Candy10$1 implements Runnable { public Demo8$1() {} @Override public void run() { System.out.println(\u0026#34;running...\u0026#34;); } } 引用局部变量的匿名内部类，源代码：\npublic class Candy11 { public static void test(final int x) { Runnable runnable = new Runnable() { @Override public void run() { System.out.println(\u0026#34;ok:\u0026#34; + x); } }; } } 转换后代码：\n// 额外生成的类 final class Candy11$1 implements Runnable { int val$x; Candy11$1(int x) { this.val$x = x; } public void run() { System.out.println(\u0026#34;ok:\u0026#34; + this.val$x); } } public class Candy11 { public static void test(final int x) { Runnable runnable = new Candy11$1(x); } } 注意：这同时解释了为什么匿名内部类引用局部变量时，局部变量必须是 final 的：因为在创建 Candy11$1 对象时，将 x 的值赋值给了 Candy11$1 对象的 值后，如果不是 final 声明的 x 值发生了改变，匿名内部类则值不一致。\n4、类加载阶段\r1）加载\r将类的字节码载入方法区（1.8后为元空间，在本地内存中）中，内部采用 C++ 的 instanceKlass 描述 java 类，它的重要 ﬁeld 有： _java_mirror 即 java 的类镜像，例如对 String 来说，它的镜像类就是 String.class，作用是把 klass 暴露给 java 使用 _super 即父类 _ﬁelds 即成员变量 _methods 即方法 _constants 即常量池 _class_loader 即类加载器 _vtable 虚方法表 _itable 接口方法 如果这个类还有父类没有加载，先加载父类 加载和链接可能是交替运行的 instanceKlass保存在方法区。JDK 8以后，方法区位于元空间中，而元空间又位于本地内存中 _java_mirror则是保存在堆内存中 InstanceKlass和*.class(JAVA镜像类)互相保存了对方的地址 类的对象在对象头中保存了*.class的地址。让对象可以通过其找到方法区中的instanceKlass，从而获取类的各种信息 注意\ninstanceKlass 这样的【元数据】是存储在方法区（1.8 后的元空间内），但 _java_mirror 是存储在堆中 可以通过前面介绍的 HSDB 工具查看 2）连接\r验证 验证类是否符合 JVM规范，安全性检查 用 UE 等支持二进制的编辑器修改 HelloWorld.class 的魔数，在控制台运行 准备 为 static 变量分配空间，设置默认值\nstatic 变量在 JDK 7 之前存储于 instanceKlass 末尾，从 JDK 7 开始，存储于 _java_mirror 末尾 static 变量分配空间和赋值是两个步骤，分配空间在准备阶段完成，赋值在初始化阶段完成 如果 static 变量是 final 的基本类型，以及字符串常量，那么编译阶段值就确定了，赋值在准备阶段完成 如果 static 变量是 final 的，但属于引用类型，那么赋值也会在初始化阶段完成将常量池中的符号引用解析为直接引用 public class Code_22_AnalysisTest { public static void main(String[] args) throws ClassNotFoundException, IOException { ClassLoader classLoader = Code_22_AnalysisTest.class.getClassLoader(); Class\u0026lt;?\u0026gt; c = classLoader.loadClass(\u0026#34;cn.ali.jvm.test.C\u0026#34;); // new C(); System.in.read(); } } class C { D d = new D(); } class D { } 3）初始化\r()v 方法\r初始化即调用 ()V ，虚拟机会保证这个类的『构造方法』的线程安全\n发生的时机\r概括得说，类初始化是【懒惰的】\nmain 方法所在的类，总会被首先初始化 首次访问这个类的静态变量或静态方法时 子类初始化，如果父类还没初始化，会引发 子类访问父类的静态变量，只会触发父类的初始化 Class.forName new 会导致初始化 不会导致类初始化的情况\n访问类的 static final 静态常量（基本类型和字符串）不会触发初始化 类对象.class 不会触发初始化 创建该类的数组不会触发初始化 public class Load1 { static { System.out.println(\u0026#34;main init\u0026#34;); } public static void main(String[] args) throws ClassNotFoundException { // 1. 静态常量（基本类型和字符串）不会触发初始化 // System.out.println(B.b); // 2. 类对象.class 不会触发初始化 // System.out.println(B.class); // 3. 创建该类的数组不会触发初始化 // System.out.println(new B[0]); // 4. 不会初始化类 B，但会加载 B、A // ClassLoader cl = Thread.currentThread().getContextClassLoader(); // cl.loadClass(\u0026#34;cn.ali.jvm.test.classload.B\u0026#34;); // 5. 不会初始化类 B，但会加载 B、A // ClassLoader c2 = Thread.currentThread().getContextClassLoader(); // Class.forName(\u0026#34;cn.ali.jvm.test.classload.B\u0026#34;, false, c2); // 1. 首次访问这个类的静态变量或静态方法时 // System.out.println(A.a); // 2. 子类初始化，如果父类还没初始化，会引发 // System.out.println(B.c); // 3. 子类访问父类静态变量，只触发父类初始化 // System.out.println(B.a); // 4. 会初始化类 B，并先初始化类 A // Class.forName(\u0026#34;cn.ali.jvm.test.classload.B\u0026#34;); } } class A { static int a = 0; static { System.out.println(\u0026#34;a init\u0026#34;); } } class B extends A { final static double b = 5.0; static boolean c = false; static { System.out.println(\u0026#34;b init\u0026#34;); } } 4）练习\r从字节码分析，使用 a，b，c 这三个常量是否会导致 E 初始化\npublic class Load2 { public static void main(String[] args) { System.out.println(E.a); System.out.println(E.b); // 会导致 E 类初始化，因为 Integer 是包装类 System.out.println(E.c); } } class E { public static final int a = 10; public static final String b = \u0026#34;hello\u0026#34;; public static final Integer c = 20; static { System.out.println(\u0026#34;E cinit\u0026#34;); } } 典型应用 - 完成懒惰初始化单例模式\npublic class Singleton { private Singleton() { } // 内部类中保存单例 private static class LazyHolder { static final Singleton INSTANCE = new Singleton(); } // 第一次调用 getInstance 方法，才会导致内部类加载和初始化其静态成员 public static Singleton getInstance() { return LazyHolder.INSTANCE; } } 以上的实现特点是：\n懒惰实例化 初始化时的线程安全是有保障的 5、类加载器\r类加载器虽然只用于实现类的加载动作，但它在Java程序中起到的作用却远超类加载阶段 对于任意一个类，都必须由加载它的类加载器和这个类本身一起共同确立其在 Java 虚拟机中的唯一性，每一个类加载器，都拥有一个独立的类名称空间。这句话可以表达得更通俗一些：比较两个类是否“相等”，只有在这两个类是由同一个类加载器加载的前提下才有意义，否则，即使这两个类来源于同一个 Class 文件，被同一个 Java 虚拟机加载，只要加载它们的类加载器不同，那这两个类就必定不相等！ 以JDK 8为例\n名称 加载的类 说明 Bootstrap ClassLoader（启动类加载器） JAVA_HOME/jre/lib 无法直接访问 Extension ClassLoader(拓展类加载器) JAVA_HOME/jre/lib/ext 上级为Bootstrap，显示为null Application ClassLoader(应用程序类加载器) classpath 上级为Extension 自定义类加载器 自定义 上级为Application 1）启动类的加载器\r可通过在控制台输入指令，使得类被启动类加器加载\n2）扩展类的加载器\r如果 classpath 和 JAVA_HOME/jre/lib/ext 下有同名类，加载时会使用拓展类加载器加载。当应用程序类加载器发现拓展类加载器已将该同名类加载过了，则不会再次加载。\n3）双亲委派模式\r双亲委派模式，即调用类加载器ClassLoader 的 loadClass 方法时，查找类的规则。 loadClass源码\nprotected Class\u0026lt;?\u0026gt; loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { // 首先查找该类是否已经被该类加载器加载过了 Class\u0026lt;?\u0026gt; c = findLoadedClass(name); // 如果没有被加载过 if (c == null) { long t0 = System.nanoTime(); try { // 看是否被它的上级加载器加载过了 Extension 的上级是Bootstarp，但它显示为null if (parent != null) { c = parent.loadClass(name, false); } else { // 看是否被启动类加载器加载过 c = findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { // ClassNotFoundException thrown if class not found // from the non-null parent class loader //捕获异常，但不做任何处理 } if (c == null) { // 如果还是没有找到，先让拓展类加载器调用 findClass 方法去找到该类，如果还是没找到，就抛出异常 // 然后让应用类加载器去找 classpath 下找该类 long t1 = System.nanoTime(); c = findClass(name); // 记录时间 sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0); sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1); sun.misc.PerfCounter.getFindClasses().increment(); } } if (resolve) { resolveClass(c); } return c; } } 4）自定义类加载器\r使用场景\n想加载非 classpath 随意路径中的类文件 通过接口来使用实现，希望解耦时，常用在框架设计 这些类希望予以隔离，不同应用的同名类都可以加载，不冲突，常见于 tomcat 容器 步骤\n继承 ClassLoader 父类 要遵从双亲委派机制，重写 ﬁndClass 方法 不是重写 loadClass 方法，否则不会走双亲委派机制 读取类文件的字节码 调用父类的 deﬁneClass 方法来加载类 使用者调用该类加载器的 loadClass 方法 破坏双亲委派模式\n双亲委派模型的第一次“被破坏”其实发生在双亲委派模型出现之前——即JDK1.2面世以前的“远古”时代 建议用户重写findClass()方法，在类加载器中的loadClass()方法中也会调用该方法 双亲委派模型的第二次“被破坏”是由这个模型自身的缺陷导致的 如果有基础类型又要调用回用户的代码，此时也会破坏双亲委派模式 双亲委派模型的第三次“被破坏”是由于用户对程序动态性的追求而导致的 这里所说的“动态性”指的是一些非常“热”门的名词：代码热替换（Hot Swap）、模块热部署（Hot Deployment）等 6、运行期优化\r1）即时编译\r分层编译 JVM 将执行状态分成了 5 个层次：\n0层：解释执行，用解释器将字节码翻译为机器码 1层：使用 C1 即时编译器编译执行（不带 proﬁling） 2层：使用 C1 即时编译器编译执行（带基本的profiling） 3层：使用 C1 即时编译器编译执行（带完全的profiling） 4层：使用 C2 即时编译器编译执行 proﬁling 是指在运行过程中收集一些程序执行状态的数据，例如【方法的调用次数】，【循环的 回边次数】等\n即时编译器（JIT）与解释器的区别\n解释器 将字节码解释为机器码，下次即使遇到相同的字节码，仍会执行重复的解释 是将字节码解释为针对所有平台都通用的机器码 即时编译器 将一些字节码编译为机器码，并存入 Code Cache，下次遇到相同的代码，直接执行，无需再编译 根据平台类型，生成平台特定的机器码 对于大部分的不常用的代码，我们无需耗费时间将其编译成机器码，而是采取解释执行的方式运行；另一方面，对于仅占据小部分的热点代码，我们则可以将其编译成机器码，以达到理想的运行速度。 执行效率上简单比较一下 Interpreter \u0026lt; C1 \u0026lt; C2，总的目标是发现热点代码（hotspot名称的由 来），并优化这些热点代码。 逃逸分析 逃逸分析（Escape Analysis）简单来讲就是，Java Hotspot 虚拟机可以分析新创建对象的使用范围，并决定是否在 Java 堆上分配内存的一项技术\n逃逸分析的 JVM 参数如下：\n开启逃逸分析：-XX:+DoEscapeAnalysis 关闭逃逸分析：-XX:-DoEscapeAnalysis 显示分析结果：-XX:+PrintEscapeAnalysis 逃逸分析技术在 Java SE 6u23+ 开始支持，并默认设置为启用状态，可以不用额外加这个参数\n对象逃逸状态\n全局逃逸（GlobalEscape）\n即一个对象的作用范围逃出了当前方法或者当前线程，有以下几种场景： 对象是一个静态变量 对象是一个已经发生逃逸的对象 对象作为当前方法的返回值 参数逃逸（ArgEscape）\n即一个对象被作为方法参数传递或者被参数引用，但在调用过程中不会发生全局逃逸，这个状态是通过被调方法的字节码确定的 没有逃逸\n即方法中的对象没有发生逃逸 逃逸分析优化 针对上面第三点，当一个对象没有逃逸时，可以得到以下几个虚拟机的优化\n锁消除 我们知道线程同步锁是非常牺牲性能的，当编译器确定当前对象只有当前线程使用，那么就会移除该对象的同步锁 例如，StringBuffer 和 Vector 都是用 synchronized 修饰线程安全的，但大部分情况下，它们都只是在当前线程中用到，这样编译器就会优化移除掉这些锁操作 锁消除的 JVM 参数如下：\n开启锁消除：-XX:+EliminateLocks 关闭锁消除：-XX:-EliminateLocks 锁消除在 JDK8 中都是默认开启的，并且锁消除都要建立在逃逸分析的基础上\n标量替换 首先要明白标量和聚合量，基础类型和对象的引用可以理解为标量，它们不能被进一步分解。而能被进一步分解的量就是聚合量，比如：对象 对象是聚合量，它又可以被进一步分解成标量，将其成员变量分解为分散的变量，这就叫做标量替换。\n这样，如果一个对象没有发生逃逸，那压根就不用创建它，只会在栈或者寄存器上创建它用到的成员标量，节省了内存空间，也提升了应用程序性能 标量替换的 JVM 参数如下：\n开启标量替换：-XX:+EliminateAllocations 关闭标量替换：-XX:-EliminateAllocations 显示标量替换详情：-XX:+PrintEliminateAllocations 标量替换同样在 JDK8 中都是默认开启的，并且都要建立在逃逸分析的基础上\n栈上分配 当对象没有发生逃逸时，该对象就可以通过标量替换分解成成员标量分配在栈内存中，和方法的生命周期一致，随着栈帧出栈时销毁，减少了 GC 压力，提高了应用程序性能\n方法内联 内联函数 内联函数就是在程序编译时，编译器将程序中出现的内联函数的调用表达式用内联函数的函数体来直接进行替换\nJVM内联函数 C++ 是否为内联函数由自己决定，Java 由编译器决定。Java 不支持直接声明为内联函数的，如果想让他内联，你只能够向编译器提出请求: 关键字 final 修饰 用来指明那个函数是希望被 JVM 内联的，如\npublic final void doSomething() { // to do something } 总的来说，一般的函数都不会被当做内联函数，只有声明了final后，编译器才会考虑是不是要把你的函数变成内联函数\nJVM内建有许多运行时优化。首先短方法更利于JVM推断。流程更明显，作用域更短，副作用也更明显。如果是长方法JVM可能直接就跪了。\n第二个原因则更重要：方法内联\n如果JVM监测到一些小方法被频繁的执行，它会把方法的调用替换成方法体本身，如：\nprivate int add4(int x1, int x2, int x3, int x4) { //这里调用了add2方法 return add2(x1, x2) + add2(x3, x4); } private int add2(int x1, int x2) { return x1 + x2; } 方法调用被替换后\nprivate int add4(int x1, int x2, int x3, int x4) { //被替换为了方法本身 return x1 + x2 + x3 + x4; } 2）反射优化\rpublic class Reflect1 { public static void foo() { System.out.println(\u0026#34;foo...\u0026#34;); } public static void main(String[] args) throws NoSuchMethodException, InvocationTargetException, IllegalAccessException { Method foo = Demo3.class.getMethod(\u0026#34;foo\u0026#34;); for(int i = 0; i\u0026lt;=16; i++) { foo.invoke(null); } } } foo.invoke 前面 0 ~ 15 次调用使用的是 MethodAccessor 的 NativeMethodAccessorImpl 实现 invoke 方法源码\n@CallerSensitive public Object invoke(Object obj, Object... args) throws IllegalAccessException, IllegalArgumentException, InvocationTargetException { if (!override) { if (!Reflection.quickCheckMemberAccess(clazz, modifiers)) { Class\u0026lt;?\u0026gt; caller = Reflection.getCallerClass(); checkAccess(caller, clazz, obj, modifiers); } } //MethodAccessor是一个接口，有3个实现类，其中有一个是抽象类 MethodAccessor ma = methodAccessor; // read volatile if (ma == null) { ma = acquireMethodAccessor(); } return ma.invoke(obj, args); } 会由 DelegatingMehodAccessorImpl 去调用 NativeMethodAccessorImpl NativeMethodAccessorImpl 源码\nclass NativeMethodAccessorImpl extends MethodAccessorImpl { private final Method method; private DelegatingMethodAccessorImpl parent; private int numInvocations; NativeMethodAccessorImpl(Method var1) { this.method = var1; } //每次进行反射调用，会让numInvocation与ReflectionFactory.inflationThreshold的值（15）进行比较，并使使得numInvocation的值加一 //如果numInvocation\u0026gt;ReflectionFactory.inflationThreshold，则会调用本地方法invoke0方法 public Object invoke(Object var1, Object[] var2) throws IllegalArgumentException, InvocationTargetException { if (++this.numInvocations \u0026gt; ReflectionFactory.inflationThreshold() \u0026amp;\u0026amp; !ReflectUtil.isVMAnonymousClass(this.method.getDeclaringClass())) { MethodAccessorImpl var3 = (MethodAccessorImpl)(new MethodAccessorGenerator()).generateMethod(this.method.getDeclaringClass(), this.method.getName(), this.method.getParameterTypes(), this.method.getReturnType(), this.method.getExceptionTypes(), this.method.getModifiers()); this.parent.setDelegate(var3); } return invoke0(this.method, var1, var2); } void setParent(DelegatingMethodAccessorImpl var1) { this.parent = var1; } private static native Object invoke0(Method var0, Object var1, Object[] var2); } //ReflectionFactory.inflationThreshold()方法的返回值 private static int inflationThreshold = 15; 一开始if条件不满足，就会调用本地方法 invoke0 随着 numInvocation 的增大，当它大于 ReflectionFactory.inflationThreshold 的值 16 时，就会本地方法访问器替换为一个运行时动态生成的访问器，来提高效率 这时会从反射调用变为正常调用，即直接调用 Reflect1.foo() 阿里开源工具：arthas-boot\n五、内存模型\r参考这篇文章！\n本文转自 https://blog.csdn.net/weixin_50280576/article/details/113784268?spm=1001.2101.3001.10796，如有侵权，请联系删除。\n","date":"2026-04-11T00:00:00Z","permalink":"/p/jvm-%E7%B1%BB%E5%8A%A0%E8%BD%BD%E5%AD%97%E8%8A%82%E7%A0%81%E6%8A%80%E6%9C%AF%E4%B8%8E%E5%86%85%E5%AD%98%E6%A8%A1%E5%9E%8B/","title":"JVM 类加载、字节码技术与内存模型"},{"content":"LCR 095 最长公共子序列\rhttps://leetcode.cn/problems/qJnOS7/description/\n递归\rclass Solution { public: int max_len(string text1,int index1,string text2,int index2){ if(index1\u0026gt;=text1.size()) return 0; if(index2\u0026gt;=text2.size()) return 0; int next_index = 0; bool flag = false; for(int i = index1;i\u0026lt;text1.size();i++){ next_index = i; if(text1[i]==text2[index2]){ flag =true; break; } } if(flag){ int you = 1+max_len(text1,next_index+1,text2,index2+1); int wu = max_len(text1,index1,text2,index2+1); return max(you,wu); } return max_len(text1,index1,text2,index2+1); } int longestCommonSubsequence(string text1, string text2) { return max_len(text1,0,text2,0); } }; 这里犯了一个错误，就是这个for循环，在本层不应该使用for循环找到下层有效位置，而是直接令 next_index = index1+1 。\n您可能选择使用 `for` 循环来查找下一个匹配字符，是因为您试图找到 `text1` 中从 `index1` 开始的第一个与 `text2[index2]` 匹配的字符位置。这种方法在某些情况下是有效的，但在递归函数中使用这种方式可能会导致代码结构变得复杂，也容易出现逻辑错误。\r递归函数本身具有自我调用的特性，通常能够更自然地处理索引的递增和比较，而不需要显式地使用循环来搜索。使用递归时，重点是正确地定义递归的基本情况和递归步骤，而不是通过循环来手动控制迭代过程。\r在处理字符串的问题中，如寻找最长公共子序列，递归函数能够更清晰地表达问题的解决方法。通过直接传递 `index1` 和 `index2` 的方式，递归调用可以自然地处理字符串的字符比较和递增，而不需要额外的迭代或查找操作。\r总结来说，使用递归函数时，尽量避免使用循环来查找下一个匹配字符，而应该利用递归的自我调用特性来处理索引的移动和字符的比较，以保持代码简洁和逻辑清晰。 class Solution { public: vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt; dp; int max_len(string\u0026amp; text1, int index1, string\u0026amp; text2, int index2) { if (index1 \u0026gt;= text1.size() || index2 \u0026gt;= text2.size()) return 0; if (dp[index1][index2] != -1) return dp[index1][index2]; if (text1[index1] == text2[index2]) { dp[index1][index2] = 1 + max_len(text1, index1 + 1, text2, index2 + 1); } else { dp[index1][index2] = max(max_len(text1, index1 + 1, text2, index2), max_len(text1, index1, text2, index2 + 1)); } return dp[index1][index2]; } int longestCommonSubsequence(string text1, string text2) { dp = vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt;(text1.size(), vector\u0026lt;int\u0026gt;(text2.size(), -1)); return max_len(text1, 0, text2, 0); } }; 如果两个字符串在当前位置的字符不匹配，就需要考虑跳过其中一个字符串的当前字符，然后比较剩余部分。具体来说： 可以跳过 text2 的当前字符，即 max_len(text1，index1，text2，index2 + 1)。 或者可以跳过 text1 的当前字符，即 max_len(text1，index1 + 1，text2，index2)。 在这两种情况下，递归调用分别探索了跳过一个字符后的可能性，并通过比较返回其中较大的结果。 回溯\r在将回溯变为动态规划时很困难，相比之下选和不选的问题较为简单。\nclass Solution { public: vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt; dp; int max_sub(string text1,int index1,string text2,int index2){ if(index1==text1.size()||index2==text2.size()) return 0; if(dp[index1][index2]!=-1) return dp[index1][index2]; int count = 0; for(int i = index1;i\u0026lt;text1.size();i++){ for(int j = index2;j\u0026lt;text2.size();j++){ if(text1[i]==text2[j]){ int remian = max_sub(text1,i+1,text2,j+1); count = max(remian+1,count); } } } dp[index1][index2] = count; return count; } int longestCommonSubsequence(string text1, string text2) { dp = vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt;(text1.size(), vector\u0026lt;int\u0026gt;(text2.size(), -1)); return max_sub(text1,0,text2,0); } }; 选和不选\rhttps://www.bilibili.com/video/BV1TM4y1o7ug/\n看成子集问题，本质是选不选的问题\n对于两个序列的开头两个字母：x 和 y 是否要加到公共子序列种，分别有选和不选一共有4种情况。\n选x，选y，这时要求其相等，否则不能加到子序列种。\n不选x，选y\n选x，不选y\n不选x，不选y，这种情况子问题和第一种一样，但是第一种加了个1，所以这种情况一定小于选x和选y\n或者说类似之前的回溯问题，假设有全局变量lcs 是一个数组；本层是 选一个字母加入到lcs中，那么本层有几种情况呢：有可能两个数相等，那么就直接加入到lcs，index分别后移一位；有可能不等，那么本层就不会加入到lcs，那下一层如何调用呢，这里x 和y 必然有一个要被丢弃掉：1.只丢弃其中一个 2.都丢弃，但是这样index分别后移一位，这和两数相等的情况是一样的，但是本层得到的值比之前的少一个，省略掉了。\nclass Solution { public: int max_sub(string text1,int index1,string text2,int index2){ if(index1==text1.size()||index2==text2.size()) return 0; //选x，选y if(text1[index1]==text2[index2]) return 1+max_sub(text1,index1+1,text2,index2+1); // 不相等，那么最后的lcs中必然不可能同时存在这两个，需要去掉一个 // index2+1就是 text2 中的去掉了一个。 return max(max_sub(text1,index1,text2,index2+1),max_sub(text1,index1+1,text2,index2)); //都不选就直接省略掉了。 } int longestCommonSubsequence(string text1, string text2) { return max_sub(text1,0,text2,0); } }; 动规\rclass Solution { public: int longestCommonSubsequence(string text1, string text2) { int n = text1.length(), m = text2.length(); int dp[n + 1][m + 1]; memset(dp, 0, sizeof dp); for (int i = 1; i \u0026lt;= n; i++) for (int j = 1; j \u0026lt;=m ; j++) if (text1[i - 1] == text2[j - 1]) dp[i][j] = dp[i - 1][j - 1] + 1; else dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]); return dp[n][m]; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/lcr-095-%E6%9C%80%E9%95%BF%E5%85%AC%E5%85%B1%E5%AD%90%E5%BA%8F%E5%88%97/","title":"LCR 095 最长公共子序列"},{"content":"LeetCode 1004 最大连续 1 的个数\r🟡 中等 https://leetcode.cn/problems/max-consecutive-ones-iii/description/\n思路\r窗口内容：最后经过替换后的连续1。 长度限制：无 窗口内元素限制： 窗口内元素0个数要小于k。 这里 zero_used 一开始是小数，故while中是大数， 答案在小数内。\nclass Solution { public: int longestOnes(vector\u0026lt;int\u0026gt;\u0026amp; nums, int k) { int zero_used = 0; int left = 0; int res = 0; for (int i = 0; i \u0026lt; nums.size(); i++) { if (nums[i] == 0 ) { zero_used++; } while(zero_used\u0026gt;k){ if(nums[left]==0){ zero_used--; } left++; } res = max(res, i - left + 1); } return res; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-1004-%E6%9C%80%E5%A4%A7%E8%BF%9E%E7%BB%AD-1-%E7%9A%84%E4%B8%AA%E6%95%B0/","title":"LeetCode 1004 最大连续 1 的个数"},{"content":"LeetCode 1031 两个非重叠子数组的最大和\r🟡 中等 https://leetcode.cn/problems/maximum-sum-of-two-non-overlapping-subarrays/description/\n思路\r一个容易理解的解法：\nhttps://leetcode.cn/problems/maximum-sum-of-two-non-overlapping-subarrays/solutions/459960/qian-zhui-he-bian-li-onshi-jian-fu-za-du-ji-ke-qiu/\n这个思路先从“两数和最大值”类比出发：右侧枚举右端点，同时维护左侧历史最大值 left_max，每次更新 res = max(res, nums[i] + left_max)。本题只是把“一个数”替换成“一个固定长度子数组”。\n记录前缀和可以求任意长度的子数组和。\n这题下标细节较多，写代码时要格外注意边界。\nclass Solution { public: int maxSumTwoNoOverlap(vector\u0026lt;int\u0026gt;\u0026amp; nums, int firstLen, int secondLen) { vector\u0026lt;int\u0026gt; pre_sum(nums.size()+1,0); for(int i = 0;i\u0026lt;=nums.size()-1;i++){ pre_sum[i+1] = pre_sum[i]+nums[i];//包含nums[i]-\u0026gt;pre_sum[i+1] } int res = 0; int max_left_all = 0; //遍历次数 为secondLen+1 for(int i = 0+firstLen-1;i\u0026lt;=nums.size()-1-(secondLen+1)+1;i++){ max_left_all = max(max_left_all,pre_sum[i+1]-pre_sum[i-(firstLen+1)+1+1]); res = max(res,max_left_all+pre_sum[i+(secondLen+1)-1+1]-pre_sum[i+1]); } int max_left_all_sec = 0; for(int i = 0+secondLen-1;i\u0026lt;=nums.size()-1-(firstLen+1)+1;i++){ max_left_all_sec = max(max_left_all_sec,pre_sum[i+1]-pre_sum[i-(secondLen+1)+1+1]); res = max(res,max_left_all_sec+pre_sum[i+(firstLen+1)-1+1]-pre_sum[i+1]); } return res; } }; 也可以把 for 循环抽成函数：\nclass Solution { public: int get(int left,int right,int nums_size,vector\u0026lt;int\u0026gt;\u0026amp; pre_sum){ int res = 0; int max_left_all = 0; //遍历次数 为secondLen+1 for(int i = 0+left-1;i\u0026lt;=nums_size-1-(right+1)+1;i++){ max_left_all = max(max_left_all,pre_sum[i+1]-pre_sum[i-(left+1)+1+1]); res = max(res,max_left_all+pre_sum[i+(right+1)-1+1]-pre_sum[i+1]); } return res; } int maxSumTwoNoOverlap(vector\u0026lt;int\u0026gt;\u0026amp; nums, int firstLen, int secondLen) { vector\u0026lt;int\u0026gt; pre_sum(nums.size()+1,0); for(int i = 0;i\u0026lt;=nums.size()-1;i++){ pre_sum[i+1] = pre_sum[i]+nums[i];//包含nums[i]-\u0026gt;pre_sum[i+1] } return max(get(firstLen,secondLen,nums.size(),pre_sum),get(secondLen,firstLen,nums.size(),pre_sum)); } }; 也可以一遍过：从 L + M 开始遍历，同时讨论 LM 和 ML 两种顺序。\nint maxSumTwoNoOverlap(vector\u0026lt;int\u0026gt;\u0026amp; A, int L, int M) { for (int i = 1; i \u0026lt; A.size(); ++i) A[i] += A[i - 1]; int res = A[L + M - 1], Lmax = A[L - 1], Mmax = A[M - 1]; for (int i = L + M; i \u0026lt; A.size(); ++i) { Lmax = max(Lmax, A[i - M] - A[i - L - M]); Mmax = max(Mmax, A[i - L] - A[i - L - M]); res = max(res, max(Lmax + A[i] - A[i - M], Mmax + A[i] - A[i - L])); } return res; } ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-1031-%E4%B8%A4%E4%B8%AA%E9%9D%9E%E9%87%8D%E5%8F%A0%E5%AD%90%E6%95%B0%E7%BB%84%E7%9A%84%E6%9C%80%E5%A4%A7%E5%92%8C/","title":"LeetCode 1031 两个非重叠子数组的最大和"},{"content":"LeetCode 1049 最后一块石头的重量 II\r🟡中等\nhttps://leetcode.cn/problems/last-stone-weight-ii/description/\n逆天思路：将石头分为两堆，两堆之差就是最后的答案。\n那么问题就转化为了背包问题，背包的最大容量是 sum/2 ，求最接近最大容量的值。\n递归\nclass Solution { public: int add_pack(vector\u0026lt;int\u0026gt;\u0026amp; stones,int index,int cap){ if(cap==0) return 0; if(index==stones.size()) return cap; if(stones[index]\u0026gt;cap){ return add_pack(stones,index+1,cap); } return min(add_pack(stones,index+1,cap),add_pack(stones,index+1,cap-stones[index])); } int lastStoneWeightII(vector\u0026lt;int\u0026gt;\u0026amp; stones) { int sum = accumulate(stones.begin(),stones.end(),0); int target = sum/2; int remain = add_pack(stones,0,target); if(sum%2){ return 2*remain+1; }else{ return 2*remain; } } }; 动态规划\n这里是从cap里减，也可以改成从0开始累加，找到最接近target的值，最后直接返回 sum-2*返回值。\nclass Solution { public: int lastStoneWeightII(vector\u0026lt;int\u0026gt;\u0026amp; stones) { int sum = accumulate(stones.begin(), stones.end(), 0); int target = sum / 2; int dp[stones.size() + 1][target + 1]; memset(dp, 0, sizeof dp); for (int i = 0; i \u0026lt;= target; i++) { dp[stones.size()][i] = i; } for (int i = 0; i \u0026lt;= stones.size() - 1; i++) { dp[i][0] = 0; } for (int i = stones.size() - 1; i \u0026gt;= 0; i--) { for (int j = 0; j \u0026lt;= target; j++) { if (stones[i] \u0026gt; j) { dp[i][j] = dp[i + 1][j]; } else { dp[i][j] = min(dp[i + 1][j], dp[i + 1][j - stones[i]]); } } } // int remain = add_pack(stones,0,target); int remain = dp[0][target]; if (sum % 2) { return 2 * remain + 1; } else { return 2 * remain; } } }; class Solution { public: int closest(vector\u0026lt;int\u0026gt;\u0026amp; stones, int index, int target, vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt;\u0026amp; memo) { if (index == stones.size()) return 0; if (memo[index][target] != -1) return memo[index][target]; // Don\u0026#39;t take the current stone int withoutCurrent = closest(stones, index + 1, target, memo); // Take the current stone, if it doesn\u0026#39;t exceed the target int withCurrent = 0; if (stones[index] \u0026lt;= target) { withCurrent = stones[index] + closest(stones, index + 1, target - stones[index], memo); } memo[index][target] = max(withoutCurrent, withCurrent); return memo[index][target]; } int lastStoneWeightII(vector\u0026lt;int\u0026gt;\u0026amp; stones) { int sum = accumulate(stones.begin(), stones.end(), 0); int target = sum / 2; vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt; memo(stones.size(), vector\u0026lt;int\u0026gt;(target + 1, -1)); int closestWeight = closest(stones, 0, target, memo); return sum - 2 * closestWeight; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-1049-%E6%9C%80%E5%90%8E%E4%B8%80%E5%9D%97%E7%9F%B3%E5%A4%B4%E7%9A%84%E9%87%8D%E9%87%8F-ii/","title":"LeetCode 1049 最后一块石头的重量 II"},{"content":"LeetCode 1052 爱生气的书店老板\r🟡 中等 https://leetcode.cn/problems/grumpy-bookstore-owner/description/\n特殊的思路\r一般的思路：维护两个和：一个是滑窗里面的客人，一个是滑窗外面的合法客人，在滑动的时候分别维护这两个和即可。\n但是，也可以直接先求出所有合法的客人，然后把 customers 中合法的客人视作0即可，这样问题转化为求滑窗内不合法的客人最大值。\n起始问题的本质就是求滑窗内不合法的客人数量最大值，但是结果要返回总数，因此可以要维护两个和，但是特殊的思路将问题转化到本质上了。\nclass Solution { public: int maxSatisfied(vector\u0026lt;int\u0026gt;\u0026amp; customers, vector\u0026lt;int\u0026gt;\u0026amp; grumpy, int minutes) { int satisfied_count = 0; for(int i= 0 ;i\u0026lt;customers.size();i++){ if(grumpy[i]==0) satisfied_count+=customers[i]; } for(int i = 0;i\u0026lt;=0+minutes-1-1;i++){ if(grumpy[i]==1) satisfied_count+=customers[i]; } int res = 0; for(int i = minutes-1;i\u0026lt;customers.size();i++){ if(grumpy[i]==1) satisfied_count += customers[i]; res = max(res,satisfied_count); if(grumpy[i-minutes+1]==1) satisfied_count-=customers[i-minutes+1]; } return res; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-1052-%E7%88%B1%E7%94%9F%E6%B0%94%E7%9A%84%E4%B9%A6%E5%BA%97%E8%80%81%E6%9D%BF/","title":"LeetCode 1052 爱生气的书店老板"},{"content":"LeetCode 11 盛水最多的容器\r🟡 中等\nhttps://leetcode.cn/problems/container-with-most-water/description/\n首先， 双指针法设置两个指针在数组的首尾，\n考虑指针移动的规则: 如果移动长的一边， 那么容器的高(依赖于短的一边)是不变的， 而底是在减小的， 所以到最后容器的体积不会增大. 因此要移动短的一边， 底虽然减小了， 但是高可能能找到更大的一个。 class Solution { public: int maxArea(vector\u0026lt;int\u0026gt;\u0026amp; height) { int left = 0; int right = height.size()-1; int ans = 0; while(left\u0026lt;right){ int h = min(height[left],height[right]); ans = max(ans,(right-left)*h); if(height[left]\u0026lt;height[right]){ left++; }else{ right--; } } return ans; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-11-%E7%9B%9B%E6%B0%B4%E6%9C%80%E5%A4%9A%E7%9A%84%E5%AE%B9%E5%99%A8/","title":"LeetCode 11 盛水最多的容器"},{"content":"LeetCode 1156 单字符重复子串的最大长度\r🟡 中等 https://leetcode.cn/problems/swap-for-longest-repeated-character-substring/description/\n暴力滑动搜索\rO(n*n)\n对于每个开始，可以先滑动一段距离，找到第一个不同的元素，再尝试滑动第二段举例即可。\nclass Solution { public: int maxRepOpt1(string text) { int count[26]; fill(count,count+26,0); for(int i = 0;i\u0026lt;text.size();i++){ count[text[i]-\u0026#39;a\u0026#39;]++; } int res = 0; for(int i = 0;i\u0026lt;text.size();i++){ int first = i; while(first\u0026lt;text.size()\u0026amp;\u0026amp;text[first]==text[i]) first++; int second = first+1; while(second\u0026lt;text.size()\u0026amp;\u0026amp;text[second]==text[i]) second++; res = max(res,min(second-1-i+1,count[text[i]-\u0026#39;a\u0026#39;])); } return res; } }; 巧妙滑窗\r使用和[17-395至少有K个重复字符的最长子串](LeetCode 395 至少有 K 个重复字符的最长子串 - 滑动窗口解法.md) 主动限制窗口内的元素；注意到主要的元素只有26种可能，因此，可以每层循环指定主要的元素是哪个。\n第一次：滑窗限制条件为至多有1个元素不等于 a。 第二次：至多有一个元素不等于b。\n复杂度为：O(n*m) 。\nclass Solution { public: int maxRepOpt1(string text) { int count[26]; fill(count,count+26,0); for(char\u0026amp; ch:text) count[ch-\u0026#39;a\u0026#39;]++; int res = 0; for(char ch = \u0026#39;a\u0026#39;;ch\u0026lt;=\u0026#39;z\u0026#39;;ch++){ int left = 0; int not_equal = 0; for(int i = 0;i\u0026lt;text.size();i++){ not_equal+=(text[i]!=ch); while(not_equal\u0026gt;1){ not_equal-=(text[left]!=ch); left++; } res = max(res,min(i-left+1,count[ch-\u0026#39;a\u0026#39;])); } } return res; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-1156-%E5%8D%95%E5%AD%97%E7%AC%A6%E9%87%8D%E5%A4%8D%E5%AD%90%E4%B8%B2%E7%9A%84%E6%9C%80%E5%A4%A7%E9%95%BF%E5%BA%A6/","title":"LeetCode 1156 单字符重复子串的最大长度"},{"content":"LeetCode 1234 替换子串得到平衡字符串\r🟡 中等 https://leetcode.cn/problems/replace-the-substring-for-balanced-string/description/\n首先分析问题，从中间切一刀如果左边的各个值均小于等于 n/4，那么右边一定可以调整到平衡状态，反之不然。那右边一定必须到头码？不是的，右边可以提前结束，应该是除了选中的窗口内的字符，其他的字符计数都小于n/4就一定可以调整窗口内的字符得到平衡。\n这里初始 count假设是大数，因此while始终维护小数，答案是小数的情况，因此在while里面更新。\nclass Solution { public: int balancedString(string s) { int total_len = s.size(); int single_len = total_len / 4; unordered_map\u0026lt;char, int\u0026gt; count; for(int i = 0;i\u0026lt;total_len;i++){ count[s[i]]++; } if(count[\u0026#39;Q\u0026#39;]\u0026lt;=single_len\u0026amp;\u0026amp;count[\u0026#39;W\u0026#39;]\u0026lt;=single_len\u0026amp;\u0026amp;count[\u0026#39;E\u0026#39;]\u0026lt;=single_len\u0026amp;\u0026amp;count[\u0026#39;R\u0026#39;]\u0026lt;=single_len){ return 0; } int left = 0; int res = total_len; for(int i = 0;i\u0026lt;total_len;i++){ count[s[i]]--; while(count[\u0026#39;Q\u0026#39;]\u0026lt;=single_len\u0026amp;\u0026amp;count[\u0026#39;W\u0026#39;]\u0026lt;=single_len\u0026amp;\u0026amp;count[\u0026#39;E\u0026#39;]\u0026lt;=single_len\u0026amp;\u0026amp;count[\u0026#39;R\u0026#39;]\u0026lt;=single_len){ res = min(res,i-left+1); count[s[left]]++; left++; } } return res; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-1234-%E6%9B%BF%E6%8D%A2%E5%AD%90%E4%B8%B2%E5%BE%97%E5%88%B0%E5%B9%B3%E8%A1%A1%E5%AD%97%E7%AC%A6%E4%B8%B2/","title":"LeetCode 1234 替换子串得到平衡字符串"},{"content":"LeetCode 1248 统计优美子数组\r🟡 中等 https://leetcode.cn/problems/count-number-of-nice-subarrays/description/\n可以使用动态规划求解： 以 [2，2，2，1，2，2，1，2，2，2] ，限制2个1为例： class Solution { public: int numberOfSubarrays(vector\u0026lt;int\u0026gt;\u0026amp; nums, int k) { vector\u0026lt;int\u0026gt; pre(nums.size()+1,0); int count = 0; for(int i = nums.size()-1;i\u0026gt;=0;i--){ if(nums[i]%2==0){ pre[i] = pre[i+1]; count++; }else{ pre[i]=count+1; count=0; } } for(int i = 2;i\u0026lt;=k;i++){ vector\u0026lt;int\u0026gt; current(nums.size()+1,0); for(int j = nums.size()-1;j\u0026gt;=0;j--){ if(nums[j]%2==0){ current[j] = current[j+1]; }else{ current[j] = pre[j+1]; } } pre = current; } return accumulate(pre.begin(),pre.end(),0); } }; 时间复杂度：O(n*k) ，超时了。\n滑动窗口\r在找到窗口内奇数个数为k时，设置tmp，假装滑动，我们要找到以i结尾的所有合法子数组的左边界，左边界可能时当前的left，也可能时右边第一个奇数。\n使用tmp滑动到第一个奇数，所以while条件设置相反的，停在奇数后，求长度即可。\nclass Solution { public: int numberOfSubarrays(vector\u0026lt;int\u0026gt;\u0026amp; nums, int k) { int left = 0; int res = 0; int window_count = 0; for(int i = 0;i\u0026lt;nums.size();i++){ if(nums[i]%2==1){ window_count++; } while(window_count\u0026gt;k){ if(nums[left]%2==1) window_count--; left++; } if(window_count==k){ int tmp = left; while(nums[tmp]%2==0){ tmp++; } res+=tmp-left+1; } } return res; } }; 其他\rhttps://leetcode.cn/problems/count-number-of-nice-subarrays/solutions/212966/count-number-of-nice-subarrays-by-ikaruga/\nclass Solution { public: int numberOfSubarrays(vector\u0026lt;int\u0026gt;\u0026amp; nums, int k) { vector\u0026lt;int\u0026gt; odd_index; odd_index.push_back(-1); int pre_odd = 1; int ans = 0; for(int i = 0;i\u0026lt;=nums.size();i++){ if(i==nums.size()||nums[i]%2==1) odd_index.push_back(i); if(odd_index.size()-1-pre_odd+1==k+1){ int left = odd_index[pre_odd]-odd_index[pre_odd-1]; int right = i-1-odd_index[odd_index.size()-2]+1; ans +=left*right; pre_odd++; } } return ans; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-1248-%E7%BB%9F%E8%AE%A1%E4%BC%98%E7%BE%8E%E5%AD%90%E6%95%B0%E7%BB%84/","title":"LeetCode 1248 统计优美子数组"},{"content":"LeetCode 1297 子串的最大出现次数\r🟠 中等偏上\nhttps://leetcode.cn/problems/maximum-number-of-occurrences-of-a-substring/description/\n特殊的点\r如果字符串 abcf 出现了5次，那么 abc 一定不止出现了5次。也就是说：短的字符串出现的次数一定比长的出现次数多。\n因此可以用最短的限制长度 minSize 来滑动收集每种子串出现的次数，另外还要限制窗口内的字母种类数目因此窗口用 hash_map 维护，每种子串也用 hash_map 维护。\nclass Solution { public: int maxFreq(string s, int maxLetters, int minSize, int maxSize) { unordered_map\u0026lt;char,int\u0026gt; char_count; unordered_map\u0026lt;string,int\u0026gt; sub_str; for(int i = 0;i\u0026lt;=minSize-2;i++){ char_count[s[i]]++; } int max_count = 0; for(int i = minSize-1;i\u0026lt;s.size();i++){ char_count[s[i]]++; if(char_count.size()\u0026lt;=maxLetters){ sub_str[s.substr(i-minSize+1,minSize)]++; max_count = max(max_count,sub_str[s.substr(i-minSize+1,minSize)]); } char_count[s[i-minSize+1]]--; if(char_count[s[i-minSize+1]]==0){ char_count.erase(s[i-minSize+1]); } } return max_count; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-1297-%E5%AD%90%E4%B8%B2%E7%9A%84%E6%9C%80%E5%A4%A7%E5%87%BA%E7%8E%B0%E6%AC%A1%E6%95%B0/","title":"LeetCode 1297 子串的最大出现次数"},{"content":"LeetCode 131 分割回文串 - 回溯解法\rhttps://leetcode.cn/problems/palindrome-partitioning/description/\n思路\r手动模拟\rs = \u0026ldquo;aab\u0026rdquo;\n先切为 [a, ab]：左边是回文串；右边继续切 \u0026quot;ab\u0026quot;，可切为 [a, b]，也都是回文串。\n先切为 [aa, b]：左边是回文串，右边也是回文串。\n每次都先切出左侧一段，再递归处理右侧。\n对于 \u0026ldquo;aaba\u0026rdquo;\n[a, aba] 这种情况不会遗漏，因为切分位置可以放到最右侧，此时右侧为空串。\n为什么不一开始在最左侧切，让右侧保持完整 aba？因为如果允许左侧为空，path 的处理要额外加判断逻辑。\n错误代码\rfor(从每个位置切){ if 左边是回文串{ path.push(左边) qie(右边) path.pop() } } 这里还没有返回 返回的逻辑 if(s.size()==1){ path.push_back(s); result.push_back(path); return; } 这里是错误的：在返回分支中执行了 `path.push`，但没有对应的 `pop`，会导致 `path` 残留脏数据。 path.push(左边) qie(右边) path.pop() 按理来说这里 `pop` 的应该是左边片段，但由于前面的返回分支额外执行了 `push`，这里 `pop` 的反而是刚才 `return` 分支压入的 `s`，会导致结果错误。 对于 \u0026#34;aab\u0026#34; 输出是 [[\u0026#34;a\u0026#34;,\u0026#34;a\u0026#34;,\u0026#34;b\u0026#34;],[\u0026#34;a\u0026#34;,\u0026#34;aa\u0026#34;,\u0026#34;b\u0026#34;]] 可以看到最里面的 `a` 没有被正确弹出。 解决方式：不要在返回分支执行 `path` 的 `push/pop`。因此终止条件改为“字符串为空时保存结果并返回”，而不是“长度为 1 时处理”。 class Solution { public: vector\u0026lt;vector\u0026lt;string\u0026gt;\u0026gt; result; vector\u0026lt;string\u0026gt; path; bool is_huiwen(string s){ for(int i = 0, j = s.size()-1;i\u0026lt;=j;i++,j--){ if(s[i]!=s[j])return false; } return true; } void qie(string s){ if(s.size()==1){ path.push_back(s); result.push_back(path); return; } for(int i = 1;i\u0026lt;=s.size();i++){ string left = s.substr(0,i); string right = s.substr(i,s.size()-i); if(is_huiwen(left)){ path.push_back(left); qie(right); path.pop_back(); } } } vector\u0026lt;vector\u0026lt;string\u0026gt;\u0026gt; partition(string s) { qie(s); return result; } }; 正确代码\rclass Solution { public: vector\u0026lt;vector\u0026lt;string\u0026gt;\u0026gt; result; vector\u0026lt;string\u0026gt; path; bool is_huiwen(string s){ for(int i = 0, j = s.size()-1;i\u0026lt;=j;i++,j--){ if(s[i]!=s[j])return false; } return true; } void qie(string s){ //遍历完所有情况, 右侧为空, 传入s为空, 此时返回 if(s.size()==0){ // path.push_back(s); result.push_back(path); return; } for(int i = 1;i\u0026lt;=s.size();i++){ string left = s.substr(0,i); string right = s.substr(i,s.size()-i); if(is_huiwen(left)){ path.push_back(left); qie(right); path.pop_back(); } } } vector\u0026lt;vector\u0026lt;string\u0026gt;\u0026gt; partition(string s) { qie(s); return result; } }; 理解\r做完后的理解， 一开始这个问题一直没有找到一个不变的流程，但后面想到了:\n从串的前面(遍历式)截下来一个回文串并加到path中，不断地重复直到串为空.\n仍然是往path中加值.\n这个不变的流程是 函数的语义: 从串的前面不断切出回文串\nif(s为空){ result保存, 返回 } for(遍历切割位置){ 切下来的左边为回文串{ 左边加入到path qie(右边) path pop } } ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-131-%E5%88%86%E5%89%B2%E5%9B%9E%E6%96%87%E4%B8%B2-%E5%9B%9E%E6%BA%AF%E8%A7%A3%E6%B3%95/","title":"LeetCode 131 分割回文串 - 回溯解法"},{"content":"LeetCode 1358 包含所有三种字符的子串数目\r🟡 中等 https://leetcode.cn/problems/number-of-substrings-containing-all-three-characters/description/\n传统滑窗\r这是收集 左边开始到i的数组个数。\nclass Solution { public: int numberOfSubstrings(string s) { unordered_map\u0026lt;char,int\u0026gt; hash_map; int left = 0; int res = 0; for(int i = 0;i\u0026lt;s.size();i++){ hash_map[s[i]]++; while(hash_map.size()\u0026gt;=3){ hash_map[s[left]]--; if(hash_map[s[left]]==0) hash_map.erase(s[left]); left++; } res+=(left-1)-0+1; } return res; } }; 也可以收集 left 到结束的子数组个数。\nclass Solution { public: int numberOfSubstrings(string s) { unordered_map\u0026lt;char,int\u0026gt; hash_map; int left = 0; int res = 0; for(int i = 0;i\u0026lt;s.size();i++){ hash_map[s[i]]++; while(hash_map.size()\u0026gt;=3){ res+=s.size()-1-i+1; hash_map[s[left]]--; if(hash_map[s[left]]==0) hash_map.erase(s[left]); left++; } } return res; } }; 动规\r思考以 i 结尾的答案，其开始位置取决于 a b c 其中最晚出现位置的最小值。\n使用 last数组维护上次 a b c 出现的最后位置。\n首先last初始不能为0，那么设置成什么呢？-2可以吗？可以，但是要额外设置判断条件。可以设置成-1，假设最小的下标为 i，那么答案因该加 i+1， 所以我们要让初始abc都没更新时，答案的更新为0，因此令 i+1 = 0，i= -1，所以初始设置last 为-1.\nclass Solution { public: int numberOfSubstrings(string s) { int last[3]; fill(last,last+3,-1); int res = 0; for(int i = 0;i\u0026lt;s.size();i++){ last[s[i]-\u0026#39;a\u0026#39;] = i; int left = min(last[0],min(last[1],last[2])); res+=left-(-1); } return res; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-1358-%E5%8C%85%E5%90%AB%E6%89%80%E6%9C%89%E4%B8%89%E7%A7%8D%E5%AD%97%E7%AC%A6%E7%9A%84%E5%AD%90%E4%B8%B2%E6%95%B0%E7%9B%AE/","title":"LeetCode 1358 包含所有三种字符的子串数目"},{"content":"LeetCode 139 单词拆分\r🟠 中等偏上\nhttps://leetcode.cn/problems/word-break/description/\n不好的思路：类似于换零钱问题，index遍历零钱。如果从字典中拿一个字符，能在主串中匹配，那么有选或不选两种可能，不匹配就不选。但是这样就要每次都要匹配一下，并且切割剩下的串不能合并，增加复杂程度。\n好的思路：index遍历主串，把index前面的切下来，去字典中比较，匹配到就有两种选择，不匹配就直接不选。好像分割回文串的问题[分割回文串](../04 回溯/LeetCode 131 分割回文串 - 回溯解法.md)\n根据 [分割回文串](../04 回溯/LeetCode 131 分割回文串 - 回溯解法.md) 写出本题递归代码\nclass Solution { public: bool del_word(string\u0026amp; s, int pre, unordered_set\u0026lt;string\u0026gt;\u0026amp; word_set) { //如果左边分完了，返回true if (pre == s.size()) { return true; } for (int i = 1; pre + i \u0026lt;= s.size(); i++) { //如果左边是集合中的一个，那么分割右边 if (word_set.count(s.substr(pre, i))) { if (del_word(s, pre + i, word_set)) { return true; } } } return false; } bool wordBreak(string s, vector\u0026lt;string\u0026gt;\u0026amp; wordDict) { auto word_set = unordered_set\u0026lt;string\u0026gt;(); for (auto word : wordDict) { word_set.insert(word); } return del_word(s,0,word_set); } }; 改为动态规划\n分析：递归输入是pre一位变量，因此dp为一维数组，因为pre从0到size，因此大小为size+1， 本层递归返回值依赖于很多 del_word(s，pre + i，word_set) 的返回值。这些的传入变量都比pre大，因此从大的向小的遍历。\nclass Solution { public: bool del_word(string\u0026amp; s, int pre, unordered_set\u0026lt;string\u0026gt;\u0026amp; word_set) { if (pre == s.size()) { return true; } for (int i = 1; pre + i \u0026lt;= s.size(); i++) { if (word_set.count(s.substr(pre, i))) { if (del_word(s, pre + i, word_set)) { return true; } } } return false; } bool wordBreak(string s, vector\u0026lt;string\u0026gt;\u0026amp; wordDict) { auto word_set = unordered_set\u0026lt;string\u0026gt;(); for (auto word : wordDict) { word_set.insert(word); } vector\u0026lt;bool\u0026gt; dp(s.size() + 1, false); dp[s.size()] = true; for (int i = s.size() - 1; i \u0026gt;= 0; i--) { for (int len = 1; i + len \u0026lt;= s.size(); len++) { if (word_set.count(s.substr(i, len)) \u0026amp;\u0026amp; dp[i + len]) { dp[i] = true; break; } } } // return del_word(s,0,word_set); return dp[0]; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-139-%E5%8D%95%E8%AF%8D%E6%8B%86%E5%88%86/","title":"LeetCode 139 单词拆分"},{"content":"LeetCode 142 环形链表 II\r🟡 中等 https://leetcode.cn/problems/linked-list-cycle-ii/description/\n判断有无环\r设置快慢指针，while 的条件只用判断fast 和 fast-\u0026gt;next 即可：因为 fast 一定在 slow 右侧。\n/** * Definition for singly-linked list. * struct ListNode { * int val; * ListNode *next; * ListNode(int x) : val(x), next(NULL) {} * }; */ class Solution { public: ListNode *detectCycle(ListNode *head) { ListNode* slow = head; ListNode* fast = head; bool flag = false; while(fast\u0026amp;\u0026amp;fast-\u0026gt;next){ slow = slow-\u0026gt;next; fast = fast-\u0026gt;next-\u0026gt;next; if(fast==slow){ flag = true; break; } } } }; 计算入口\rF：快节点路程\rS：慢节点路程\rb：环的长度\rF = S+nb\rF = 2S\r可得：S = nb\r又：设入口距离为 a\r则当某一节点路程为 a+nb 时，该节点一定位于入口处。\r因此 令fast为head， slow不变，两个重新一起走，最后一定在入口处相遇。 /** * Definition for singly-linked list. * struct ListNode { * int val; * ListNode *next; * ListNode(int x) : val(x), next(NULL) {} * }; */ class Solution { public: ListNode *detectCycle(ListNode *head) { ListNode* slow = head; ListNode* fast = head; bool flag = false; while(fast\u0026amp;\u0026amp;fast-\u0026gt;next){ slow = slow-\u0026gt;next; fast = fast-\u0026gt;next-\u0026gt;next; if(fast==slow){ flag = true; break; } } int pos = 0; if(!flag){ return nullptr; }else{ fast = head; while(fast!=slow){ pos++; fast = fast-\u0026gt;next; slow = slow-\u0026gt;next; } return fast; } } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-142-%E7%8E%AF%E5%BD%A2%E9%93%BE%E8%A1%A8-ii/","title":"LeetCode 142 环形链表 II"},{"content":"LeetCode 1423 可获得最大点数\r🟡 中等\n正向\r思考答案的各种情况之间的联系， 左边1， 右边k-1 ;左边2， 右边k-2， 他们之间是有联系的。\n每次左边减一个， 右边加一个， 遍历完所有的情况。\nclass Solution { public: int maxScore(vector\u0026lt;int\u0026gt;\u0026amp; cardPoints, int k) { int sum = 0; int res = 0; for(int i = 0;i\u0026lt;k;i++){ sum+=cardPoints[i]; } res = sum; int left = k-1; int right = cardPoints.size()-1; for(int i = 0;i\u0026lt;k;i++){ sum-=cardPoints[left]; sum+=cardPoints[right]; res = max(res,sum); left--; right--; } return res; } }; 逆向\r可以看成滑动窗口， 两侧的最大就是中间的最大. 因此可以使用定长滑动窗口找到最小的窗口， 剩下的就是最大的答案。\n","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-1423-%E5%8F%AF%E8%8E%B7%E5%BE%97%E6%9C%80%E5%A4%A7%E7%82%B9%E6%95%B0/","title":"LeetCode 1423 可获得最大点数"},{"content":"LeetCode 143 重排链表\r🟠 中等偏上 https://leetcode.cn/problems/reorder-list/description/\n思路：找到中间节点，反转后面的节点，再两条链表拼接。\n找到中间节点：偶数为前一个 `if(fast-\u0026gt;next-\u0026gt;next){ fast = fast-\u0026gt;next-\u0026gt;next;\nslow = slow-\u0026gt;next;`\n反转链表\nListNode* fake_head = new ListNode(); ListNode* cur = slow-\u0026gt;next; slow-\u0026gt;next = nullptr; while(cur!=nullptr){ ListNode* tmp = cur-\u0026gt;next; cur-\u0026gt;next = fake_head-\u0026gt;next; fake_head-\u0026gt;next = cur; cur = tmp; } /** * Definition for singly-linked list. * struct ListNode { * int val; * ListNode *next; * ListNode() : val(0), next(nullptr) {} * ListNode(int x) : val(x), next(nullptr) {} * ListNode(int x, ListNode *next) : val(x), next(next) {} * }; */ class Solution { public: void reorderList(ListNode* head) { ListNode* fast = head; ListNode* slow = head; while(fast-\u0026gt;next!=nullptr){ if(fast-\u0026gt;next-\u0026gt;next){ fast = fast-\u0026gt;next-\u0026gt;next; slow = slow-\u0026gt;next; }else{ fast = fast-\u0026gt;next; } } //反转链表 ListNode* fake_head = new ListNode(); ListNode* cur = slow-\u0026gt;next; slow-\u0026gt;next = nullptr; while(cur!=nullptr){ ListNode* tmp = cur-\u0026gt;next; cur-\u0026gt;next = fake_head-\u0026gt;next; fake_head-\u0026gt;next = cur; cur = tmp; } // ListNode* i = head; // while(i){ // cout\u0026lt;\u0026lt;i-\u0026gt;val\u0026lt;\u0026lt;endl; // i = i-\u0026gt;next; // } ListNode* cur_ret = head; ListNode* reverse_cur = fake_head-\u0026gt;next; while(reverse_cur!=nullptr){ ListNode* tmp = reverse_cur-\u0026gt;next; reverse_cur-\u0026gt;next = cur_ret-\u0026gt;next; cur_ret-\u0026gt;next = reverse_cur; reverse_cur = tmp; cur_ret = cur_ret-\u0026gt;next-\u0026gt;next; } // cout\u0026lt;\u0026lt;slow-\u0026gt;val\u0026lt;\u0026lt;endl; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-143-%E9%87%8D%E6%8E%92%E9%93%BE%E8%A1%A8/","title":"LeetCode 143 重排链表"},{"content":"LeetCode 1438 绝对差不超过限制的最长连续子数组\r🟡 中等\nhttps://leetcode.cn/problems/longest-continuous-subarray-with-absolute-diff-less-than-or-equal-to-limit/description/\n使用单调队列 [26-239滑动窗口最大值](LeetCode 239 滑动窗口最大值.md) 只不过本题同时维护最大和最小。\nclass Solution { public: int longestSubarray(vector\u0026lt;int\u0026gt;\u0026amp; nums, int limit) { deque\u0026lt;int\u0026gt; maxDeque, minDeque; int left = 0, res = 0; for (int i = 0; i \u0026lt; nums.size(); i++) { while (!maxDeque.empty() \u0026amp;\u0026amp; nums[maxDeque.back()] \u0026lt;= nums[i]) { maxDeque.pop_back(); } while (!minDeque.empty() \u0026amp;\u0026amp; nums[minDeque.back()] \u0026gt;= nums[i]) { minDeque.pop_back(); } maxDeque.push_back(i); minDeque.push_back(i); // 如果当前窗口内的最大值和最小值之差超过limit，则移动左边界 // 因为是队列，因此最左边的元素一定在对头。 while (nums[maxDeque.front()] - nums[minDeque.front()] \u0026gt; limit) { if (maxDeque.front() == left) maxDeque.pop_front(); if (minDeque.front() == left) minDeque.pop_front(); left++; } res = max(res, i - left + 1); } return res; } }; 分块和预处理\r","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-1438-%E7%BB%9D%E5%AF%B9%E5%B7%AE%E4%B8%8D%E8%B6%85%E8%BF%87%E9%99%90%E5%88%B6%E7%9A%84%E6%9C%80%E9%95%BF%E8%BF%9E%E7%BB%AD%E5%AD%90%E6%95%B0%E7%BB%84/","title":"LeetCode 1438 绝对差不超过限制的最长连续子数组"},{"content":"LeetCode 1456 定长子串中元音个数\r🟢 简单\nhttps://leetcode.cn/problems/maximum-number-of-vowels-in-a-substring-of-given-length/description/\n思路\r先遍历一个k的大小，再开始滑动，左边如果出去一个元音就减一，右边如果进入一个元音就加一\nclass Solution { public: bool is_yuan_yin(const char\u0026amp; s){ return s == \u0026#39;a\u0026#39; || s == \u0026#39;e\u0026#39; || s == \u0026#39;i\u0026#39; || s == \u0026#39;o\u0026#39; || s == \u0026#39;u\u0026#39;; } int maxVowels(const string\u0026amp; s, int k) { int res = 0; int count = 0; // 初始窗口的元音计数 for(int i = 0; i \u0026lt; k \u0026amp;\u0026amp; i \u0026lt; s.size(); i++){ if(is_yuan_yin(s[i])){ count++; } } res = count; // 滑动窗口 for(int i = k; i \u0026lt; s.size(); i++){ if(is_yuan_yin(s[i])) count++; if(is_yuan_yin(s[i - k])) count--; res = max(res, count); if(res == k) break; // 提前退出，如果已经找到了最大的可能值 } return res; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-1456-%E5%AE%9A%E9%95%BF%E5%AD%90%E4%B8%B2%E4%B8%AD%E5%85%83%E9%9F%B3%E4%B8%AA%E6%95%B0/","title":"LeetCode 1456 定长子串中元音个数"},{"content":"LeetCode 1477 找两个和为目标值且不重复的子数组\r🟠 中等偏上\nhttps://leetcode.cn/problems/find-two-non-overlapping-sub-arrays-each-with-target-sum/description/\n视频\n方法一：前缀和+hash_table（任意数据） 方法二：滑动窗口（只适用于正数）\n题解\r使用滑动窗口找到所有合法位置。同时更新res。\n类似于求数组中两个数的最小和一样，使用 O(n) 的时间复杂度即可：维护一个值表示前面的最小值，每次更新它。\n本也想用一个数更新前面的最小长度，但是处理重复很复杂，使用数组更加方便的利用left巧妙防止重叠。\npre数组中的每个位置应该每次都更新，只是更新的值会发生变化，因此设置一个数 min_len维护每次设置的新值，每层循环都要更新pre[i] ，min_len 在找到目标子数组后更新。\nres 更新在找到目标子数组后进行更新，但是由于使用的变量可能越界，因此要设置条件保证不越界。\nclass Solution { public: int minSumOfLengths(vector\u0026lt;int\u0026gt;\u0026amp; arr, int target) { vector\u0026lt;int\u0026gt; pre(arr.size(),INT_MAX); // pre[i] 到i-1或之前的最短的合法子数组长度。 int left = 0; int sum = 0; int min_len = INT_MAX; int res = INT_MAX; for(int i = 0;i\u0026lt;arr.size();i++){ sum+=arr[i]; while(sum\u0026gt;target){ sum-=arr[left]; left++; } if(sum==target){ min_len = min(i-left+1,min_len); //已经有一个合法的组数组之后再更新res，防止下面的逻辑出问题 if(left\u0026gt;0\u0026amp;\u0026amp;pre[left-1]!=INT_MAX){ res = min(res,i-left+1+pre[left-1]); } } pre[i] = min_len; } return res==INT_MAX?-1:res; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-1477-%E6%89%BE%E4%B8%A4%E4%B8%AA%E5%92%8C%E4%B8%BA%E7%9B%AE%E6%A0%87%E5%80%BC%E4%B8%94%E4%B8%8D%E9%87%8D%E5%A4%8D%E7%9A%84%E5%AD%90%E6%95%B0%E7%BB%84/","title":"LeetCode 1477 找两个和为目标值且不重复的子数组"},{"content":"LeetCode 148 排序链表\r🟠 中等偏上 https://leetcode.cn/problems/sort-list/description/\n插入排序\r最容易想到的 插入排序。\nstart为每次遍历位置的前一个，从 fake_head开始。 cur为遍历指针，从start 开始。 min时最小值的前一个。\n找到后将min后面的节点插入到start 后面。\n/** * Definition for singly-linked list. * struct ListNode { * int val; * ListNode *next; * ListNode() : val(0), next(nullptr) {} * ListNode(int x) : val(x), next(nullptr) {} * ListNode(int x, ListNode *next) : val(x), next(next) {} * }; */ class Solution { public: ListNode* sortList(ListNode* head) { //插入排序 ListNode* fake_head = new ListNode(); fake_head-\u0026gt;next = head; ListNode* start = fake_head; //插入到start 后面. while(start-\u0026gt;next){ ListNode* cur = start;//每次遍历位置的前面一个 ListNode* min = start;//记录最小值前面一个. while(cur-\u0026gt;next){ if(cur-\u0026gt;next-\u0026gt;val\u0026lt;min-\u0026gt;next-\u0026gt;val){ min = cur; } cur = cur-\u0026gt;next; } // cout\u0026lt;\u0026lt;min-\u0026gt;next-\u0026gt;val\u0026lt;\u0026lt;endl; ListNode* min_item = min-\u0026gt;next; min-\u0026gt;next = min-\u0026gt;next-\u0026gt;next; min_item-\u0026gt;next = start-\u0026gt;next; start-\u0026gt;next = min_item; start = start-\u0026gt;next; } return fake_head-\u0026gt;next; } }; 归并排序\rhttps://leetcode.cn/problems/sort-list/description/comments/3747\nmerge_sort ：对 head 开始的链表进行排序。 merge：对l 和 r 进行合并。\n/** * Definition for singly-linked list. * struct ListNode { * int val; * ListNode *next; * ListNode() : val(0), next(nullptr) {} * ListNode(int x) : val(x), next(nullptr) {} * ListNode(int x, ListNode *next) : val(x), next(next) {} * }; */ class Solution { public: ListNode* merge_sort(ListNode* head){ if(!head||head-\u0026gt;next==nullptr) return head; ListNode* slow = head; ListNode* fast = head; ListNode* pre = nullptr; while(fast\u0026amp;\u0026amp;fast-\u0026gt;next){ pre = slow; slow = slow-\u0026gt;next; fast = fast-\u0026gt;next-\u0026gt;next; } pre-\u0026gt;next = nullptr; ListNode* l = merge_sort(head); ListNode* r = merge_sort(slow); return merge(l,r); } ListNode* merge(ListNode* l,ListNode* r){ ListNode* fake_head = new ListNode(); ListNode* cur = fake_head; while(l\u0026amp;\u0026amp;r){ if(l-\u0026gt;val\u0026lt;r-\u0026gt;val){ cur-\u0026gt;next = l; cur = cur-\u0026gt;next; l = l-\u0026gt;next; }else{ cur-\u0026gt;next = r; cur = cur-\u0026gt;next; r = r-\u0026gt;next; } } if(l){ cur-\u0026gt;next = l; } if(r){ cur-\u0026gt;next= r; } return fake_head-\u0026gt;next; } ListNode* sortList(ListNode* head) { return merge_sort(head); } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-148-%E6%8E%92%E5%BA%8F%E9%93%BE%E8%A1%A8/","title":"LeetCode 148 排序链表"},{"content":"LeetCode 15 三数之和\r🟡 中等 https://leetcode.cn/problems/3sum/description/\n首先枚举i, 那就变成了两数之和的问题 [1-167两数之和Ⅱ](LeetCode 167 两数之和 II.md) , 这就要求排序; 这里要去重, 比如 -1 -1 0 1 , 这里的两个-1 只能要一个.\nclass Solution { public: vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt; threeSum(vector\u0026lt;int\u0026gt;\u0026amp; nums) { sort(nums.begin(),nums.end()); vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt; res; int last = INT_MAX; for(int i = 0;i\u0026lt;=nums.size()-3;i++){ if(nums[i]==last) continue; last = nums[i]; int target = -nums[i]; int j = i+1; int k = nums.size()-1; while(j\u0026lt;k){ int sum = nums[j]+nums[k]; if(sum == target){ res.push_back({nums[i],nums[j],nums[k]}); //滑动j k 防止j k 重复 while(j\u0026lt;k\u0026amp;\u0026amp;nums[j]==nums[j+1]) j++; j++; while(j\u0026lt;k\u0026amp;\u0026amp;nums[k]==nums[k-1]) k--; k--; } if(sum\u0026gt;target){ k--; } if(sum\u0026lt;target){ j++; } } } return res; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-15-%E4%B8%89%E6%95%B0%E4%B9%8B%E5%92%8C/","title":"LeetCode 15 三数之和"},{"content":"LeetCode 151 反转字符串中的单词\r🟡 中等 https://leetcode.cn/problems/reverse-words-in-a-string/description/\n先分割再拼接\nclass Solution { public: string reverseWords(string s) { vector\u0026lt;string\u0026gt; res; int n = s.size(); int start = 0; int end = n-1; while(s[start]==\u0026#39; \u0026#39;)start++; while(s[end]==\u0026#39; \u0026#39;) end--; s = s.substr(start,end-start+1); s = \u0026#39; \u0026#39;+s; n = s.size(); int i = n-1,j = n-1; while(i\u0026gt;=0){ if(s[i]!=\u0026#39; \u0026#39;\u0026amp;\u0026amp;s[i+1]==\u0026#39; \u0026#39;) j = i; if(s[i]!=\u0026#39; \u0026#39;\u0026amp;\u0026amp;s[i-1]==\u0026#39; \u0026#39;) res.push_back(s.substr(i,j-i+1)); i--; } string res_s = res[0]; for(int i = 1;i\u0026lt;res.size();i++){ res_s+=\u0026#39; \u0026#39;; res_s+=res[i]; } return res_s; } }; 常数空间\r官解\nclass Solution { public: string reverseWords(string s) { int n = s.size(); int start = 0; int index = 0; reverse(s.begin(),s.end()); while(start\u0026lt;n){ if(s[start]!=\u0026#39; \u0026#39;){ int end = start; while(start\u0026lt;n\u0026amp;\u0026amp;s[start]!=\u0026#39; \u0026#39;) s[index++] = s[start++]; reverse(s.begin()+index-1-(start-1-end+1)+1,s.begin()+index-1+1); //reverse 函数 第二个下标要+1，因为end() 是n-1的后一个； s[index++] = \u0026#39; \u0026#39;; //到最后 index指向下一次写入的位置，index-1是空格，index-2是最后一个字母。 } start++; } s.erase(s.begin()+index-1,s.end()); return s; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-151-%E5%8F%8D%E8%BD%AC%E5%AD%97%E7%AC%A6%E4%B8%B2%E4%B8%AD%E7%9A%84%E5%8D%95%E8%AF%8D/","title":"LeetCode 151 反转字符串中的单词"},{"content":"LeetCode 1546 和为目标值且不重叠的子数组的最大数目\r🟠 中等偏上\nhttps://leetcode.cn/problems/maximum-number-of-non-overlapping-subarrays-with-sum-equals-target/description/\n思路\r考虑到 i之前的大子数组的答案和 到 i-1 的大数组的答案，显然， nums[i] 的加入在 i-1 的答案 基础之上多了一种可能：有一个子数组在最右边包含了 nums[i] 。max( dp[i-1] ，1+dp[i-k] ) 递归思路\r或者递归思路：传入参数0，要么后移1位和当前区分开来，要么后移一个合法组数组。\nfun(){ return max(fun(1),1+fun(k)) //0~k-1 是一个合法的子数组。 } 代码\rfor 循环中的 前缀和的更新在末尾，如果放在sum语句之后，[0,0,0] 这个用例会出错，因为本层后面还要用 hash_map ，提前更新导致错误，因此要放到最后。\nclass Solution { public: int maxNonOverlapping(vector\u0026lt;int\u0026gt;\u0026amp; nums, int target) { unordered_map\u0026lt;int,int\u0026gt; hash_map; // sum,i 包含arr[i-1]不包含 i。 hash_map[0] = 0; int sum = 0; vector\u0026lt;int\u0026gt; dp(nums.size()+1); //dp[i] 表示到i-1的答案 dp[0] = 0; for(int i = 1;i\u0026lt;=nums.size();i++){ sum+=nums[i-1]; if(hash_map.count(sum-target)){ int index = hash_map[sum-target]-1; dp[i] = max(dp[i-1],1+dp[1+index]); }else{ dp[i] = dp[i-1]; } hash_map[sum] = i; } return dp.back(); } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-1546-%E5%92%8C%E4%B8%BA%E7%9B%AE%E6%A0%87%E5%80%BC%E4%B8%94%E4%B8%8D%E9%87%8D%E5%8F%A0%E7%9A%84%E5%AD%90%E6%95%B0%E7%BB%84%E7%9A%84%E6%9C%80%E5%A4%A7%E6%95%B0%E7%9B%AE/","title":"LeetCode 1546 和为目标值且不重叠的子数组的最大数目"},{"content":"LeetCode 1574 删除最短子数组使剩余数组有序\r🟠 中等偏上\nhttps://leetcode.cn/problems/shortest-subarray-to-be-removed-to-make-array-sorted/description/\n思路： 首先删除的是子数组，那么剩下的两端一定是有序的，那么可以先从两端找到两个有序数组，再在这两个数组内滑动即可。 不要忘了可能不要左边或不要右边的情况。\nclass Solution { public: int findLengthOfShortestSubarray(vector\u0026lt;int\u0026gt;\u0026amp; arr) { int left = 0; while(left\u0026lt;arr.size()-1\u0026amp;\u0026amp;arr[left]\u0026lt;=arr[left+1]) left++; // cout\u0026lt;\u0026lt;\u0026#34;left\u0026#34;\u0026lt;\u0026lt;left\u0026lt;\u0026lt;endl; int right = arr.size()-1; while(right\u0026gt;0\u0026amp;\u0026amp;arr[right]\u0026gt;=arr[right-1]) right--; // cout\u0026lt;\u0026lt;\u0026#34;right\u0026#34;\u0026lt;\u0026lt;right\u0026lt;\u0026lt;endl; if(left\u0026gt;=right) return 0; int res = min(static_cast\u0026lt;int\u0026gt;(arr.size())-left-1,right); for(int i = 0;i\u0026lt;=left;i++){ while(right\u0026lt;arr.size()\u0026amp;\u0026amp;arr[i]\u0026gt;arr[right]){ // cout\u0026lt;\u0026lt;i\u0026lt;\u0026lt;right\u0026lt;\u0026lt;endl; right++; } res = min(res,right-i+1-2); } return res; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-1574-%E5%88%A0%E9%99%A4%E6%9C%80%E7%9F%AD%E5%AD%90%E6%95%B0%E7%BB%84%E4%BD%BF%E5%89%A9%E4%BD%99%E6%95%B0%E7%BB%84%E6%9C%89%E5%BA%8F/","title":"LeetCode 1574 删除最短子数组使剩余数组有序"},{"content":"LeetCode 16 最接近的三数之和\r🟡 中等 https://leetcode.cn/problems/3sum-closest/description/、\n类似三数之和。\n先排序，确定一个数后使用双指针。\nclass Solution { public: int threeSumClosest(vector\u0026lt;int\u0026gt;\u0026amp; nums, int target) { sort(nums.begin(),nums.end()); int n = nums.size(); long best = INT_MAX; int i = 0; while(i\u0026lt;n){ int left = i+1,right = n-1; while(left\u0026lt;right\u0026amp;\u0026amp;best!=target){ long sum = nums[i]+nums[left]+nums[right]; if(sum\u0026gt;target){ right--; } if(sum\u0026lt;target){ left++; } best = abs(sum-target)\u0026lt;abs(best-target)?sum:best; // cout\u0026lt;\u0026lt;i\u0026lt;\u0026lt;left\u0026lt;\u0026lt;right\u0026lt;\u0026lt;best\u0026lt;\u0026lt;endl; } i++; } return best; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-16-%E6%9C%80%E6%8E%A5%E8%BF%91%E7%9A%84%E4%B8%89%E6%95%B0%E4%B9%8B%E5%92%8C/","title":"LeetCode 16 最接近的三数之和"},{"content":"LeetCode 165 比较版本号\r🟡 中等 https://leetcode.cn/problems/compare-version-numbers/description/\n在经历一系列的尝试失败后，两个数据的长度不一是关键影响因素，因此对齐两个数据，所以短的应该补0，实际可以不用补，设置初始值为0即可。\nclass Solution { public: int compareVersion(string version1, string version2) { int fast1 = 0; int fast2 = 0; int n1 = version1.size(); int n2 = version2.size(); while(fast1\u0026lt;n1||fast2\u0026lt;n2){ int v1 = 0; if(fast1\u0026lt;n1){ int slow1 =fast1; while(fast1\u0026lt;n1\u0026amp;\u0026amp;version1[fast1]!=\u0026#39;.\u0026#39;) fast1++; v1 = stoi(version1.substr(slow1,fast1-1-slow1+1)); } int v2 = 0; if(fast2\u0026lt;n2){ int slow2 = fast2; while(fast2\u0026lt;n2\u0026amp;\u0026amp;version2[fast2]!=\u0026#39;.\u0026#39;) fast2++; v2 = stoi(version2.substr(slow2,fast2-1-slow2+1)); } if(v1\u0026lt;v2) return -1; if(v1\u0026gt;v2) return 1; fast1++; fast2++; } return 0; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-165-%E6%AF%94%E8%BE%83%E7%89%88%E6%9C%AC%E5%8F%B7/","title":"LeetCode 165 比较版本号"},{"content":"LeetCode 1658 将 X 减到 0 的最小操作数\r🟡 中等\nhttps://leetcode.cn/problems/minimum-operations-to-reduce-x-to-zero/description/\n和[5-1423可获得最大点数](../01 双指针/LeetCode 1423 可获得最大点数.md) 类似，但是这里是求长度，可以用滑动窗口求内部连续的和和 sum-x 的大小。\nclass Solution { public: int minOperations(vector\u0026lt;int\u0026gt;\u0026amp; nums, int x) { int all_sum = accumulate(nums.begin(),nums.end(),0); int target = all_sum-x; int left = 0; int res = -1; if(target\u0026lt;0) return -1; int sum =0; for(int i = 0;i\u0026lt;nums.size();i++){ sum+=nums[i]; while(sum\u0026gt;target\u0026amp;\u0026amp;left\u0026lt;=i){ sum-=nums[left]; left++; } if(sum==target){ res = max(res,i-left+1); } } if(res==-1){ return -1; }else{ return nums.size()-res; } } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-1658-%E5%B0%86-x-%E5%87%8F%E5%88%B0-0-%E7%9A%84%E6%9C%80%E5%B0%8F%E6%93%8D%E4%BD%9C%E6%95%B0/","title":"LeetCode 1658 将 X 减到 0 的最小操作数"},{"content":"LeetCode 167 两数之和 II\r🟡 中等\nhttps://leetcode.cn/problems/two-sum-ii-input-array-is-sorted/description/\n首先， 可以使用暴力的方法， 遍历开始和结束， 复杂度 n*n; 这样没有使用到递增这个条件. 首先left在最左边， right在最右边， 如果和大于target， 这说明右边的值太大了， 它和最小的值(left) 之和都大于target， 因此放弃最右边的， right减一; 如果小于target， 说明左边的太小了， 因此要left加一; 如此下去直到找到等于的。\nclass Solution { public: vector\u0026lt;int\u0026gt; twoSum(vector\u0026lt;int\u0026gt;\u0026amp; numbers, int target) { int left = 0; int right = numbers.size()-1; while(left\u0026lt;right){ int sum = numbers[left]+numbers[right]; if(sum==target){ return {left+1,right+1}; } if(sum\u0026gt;target) right--; if(sum\u0026lt;target) left++; } return {}; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-167-%E4%B8%A4%E6%95%B0%E4%B9%8B%E5%92%8C-ii/","title":"LeetCode 167 两数之和 II"},{"content":"LeetCode 1838 最高频元素的频数\r🟡 中等\nhttps://leetcode.cn/problems/frequency-of-the-most-frequent-element/description/\n不排序没法做，要把相近的元素放到一块。\n问题就是图中的三角区域能否被 k 填满， 找到底下最宽的宽就行；当右边右移后，左边的界限只会右移，不会左移。 另外， long rec = static_cast\u0026lt;long\u0026gt;(nums[i]) * (i - left + 1); 类型转换转如果只转最后的结果是不对的，要转操作数，转一个就行。\nclass Solution { public: int maxFrequency(vector\u0026lt;int\u0026gt;\u0026amp; nums, int k) { sort(nums.begin(), nums.end()); int left = 0; long sum = 0; long res = 0; for (int i = 0; i \u0026lt; nums.size(); i++) { sum += nums[i]; long rec = static_cast\u0026lt;long\u0026gt;(nums[i]) * (i - left + 1); while (rec - sum \u0026gt; k) { rec -= nums[i]; sum -= nums[left]; left++; } if(rec-sum\u0026lt;=k){ res = max(res,static_cast\u0026lt;long\u0026gt;(i-left+1) ); } } return res; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-1838-%E6%9C%80%E9%AB%98%E9%A2%91%E5%85%83%E7%B4%A0%E7%9A%84%E9%A2%91%E6%95%B0/","title":"LeetCode 1838 最高频元素的频数"},{"content":"LeetCode 1871 跳跃游戏 VII\r🟠 中等偏上\nhttps://leetcode.cn/problems/jump-game-vii/description/\n递归\rif(index\u0026gt;s.size()) return false if(s[index]==\u0026#39;1\u0026#39;) return false; //只能跳0 if(index==s.size()-1) return true; //下一层调用，遍历所有可能落地位置。 for(int j = i+minJump;j\u0026lt;s.size()\u0026amp;\u0026amp;j\u0026lt;=i+maxJump;j++){ if(jump(j)) return true; } return false; //都没返回成功，返回false bool jump(string s,int index,int min_jump,int max_jump){ if(index\u0026gt;s.size()-1){ return false; } if(s[index]==\u0026#39;1\u0026#39;) return false; if(index==s.size()-1){ return true; } for(int i = min_jump;i\u0026lt;=max_jump;i++){ if(jump(s,index+i,min_jump,max_jump)) return true; } return false; } 动规\rbool canReach(string s, int minJump, int maxJump) { // return jump(s,0,minJump,maxJump); vector\u0026lt;bool\u0026gt; dp(s.size(),false); if(s[s.size()-1]==\u0026#39;0\u0026#39;){ dp[s.size()-1] = true; } else{ return false; } // for(int j = i+minJump;j\u0026lt;=i+maxJump\u0026amp;\u0026amp;j\u0026lt;s.size();j++){ // if(dp[j]==true){ // dp[i] = true; // break; // } // } } return dp[0]; } 在内层循环中遍历所有可能位置，可以用滑窗优化。 但是边界位置要处理号。\nbool canReach(string s, int minJump, int maxJump) { // return jump(s,0,minJump,maxJump); vector\u0026lt;bool\u0026gt; dp(s.size(),false); if(s[s.size()-1]==\u0026#39;0\u0026#39;){ dp[s.size()-1] = true; } else{ return false; } int true_count = 0; for(int i = s.size()-2;i\u0026gt;=0;i--){ if(i+minJump\u0026lt;=s.size()-1){ true_count+=dp[i+minJump]==true?1:0; } if(i+maxJump+1\u0026lt;=s.size()-1){ true_count-=dp[i+maxJump+1]==true?1:0; } dp[i] = true_count\u0026gt;0; if(s[i]==\u0026#39;1\u0026#39;){ dp[i] = false; } // for(int j = i+minJump;j\u0026lt;=i+maxJump\u0026amp;\u0026amp;j\u0026lt;s.size();j++){ // if(dp[j]==true){ // dp[i] = true; // break; // } // } } return dp[0]; } ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-1871-%E8%B7%B3%E8%B7%83%E6%B8%B8%E6%88%8F-vii/","title":"LeetCode 1871 跳跃游戏 VII"},{"content":"LeetCode 1888 使二进制字符串交替的最少反转次数\r🟠 中等偏上\nhttps://leetcode.cn/problems/minimum-number-of-flips-to-make-the-binary-string-alternating/description/\n朴素思路\r枚举拼接位置，对拼接后的字符串检查转为 ‘01’ 或 ‘10’ 。时间复杂度 O(n*n) 。\nclass Solution { public: int minFlips(string s) { int res = s.size(); for(int len = 0;len\u0026lt;s.size();len++){ string pre_s = s.substr(0,len); string ss = s.substr(len,s.size()-len+1)+pre_s; // 01 int count_1 = 0; for(int i = 0;i\u0026lt;s.size();i++){ if(i%2==0\u0026amp;\u0026amp;ss[i]!=\u0026#39;0\u0026#39;) count_1++; if(i%2==1\u0026amp;\u0026amp;ss[i]!=\u0026#39;1\u0026#39;) count_1++; } // 10 int count_2 = 0; for(int i = 0;i\u0026lt;s.size();i++){ if(i%2==0\u0026amp;\u0026amp;ss[i]!=\u0026#39;1\u0026#39;) count_2++; if(i%2==1\u0026amp;\u0026amp;ss[i]!=\u0026#39;0\u0026#39;) count_2++; } res = min(res,min(count_1,count_2)); } return res; } }; O(n) 思路\rhttps://www.bilibili.com/video/BV13h411a78f/?spm_id_from=333.337.search-card.all.click\u0026vd_source=a9a24992f7f570a16d5a331e8fed9f0d\n拼接有如下几种\n这种拼接是 前面的一节 和从后往前的一截进行拼接，经典两次遍历\n0101 10101 -\u0026gt; 01101 0101\r1010 01010 -\u0026gt; 第一段的开头和第二段的末尾分别是 0 1或 1 0，\n因此，设置两个数组pre_1 last_0记录 以1开头到index的（使之变为01交替）反转次数，以末尾0向前到index （使之变为01交替）的反转次数。\nclass Solution { public: int minFlips(string s) { vector\u0026lt;int\u0026gt; pre_1(s.size()+1,0); // pre_1[i] -\u0026gt; 包含 i-1; 包含 i -\u0026gt; pre_1[i+1] vector\u0026lt;int\u0026gt; pre_0(s.size()+1,0); vector\u0026lt;int\u0026gt; last_0(s.size()+1,0); //last_0[i] -\u0026gt; 包含i vector\u0026lt;int\u0026gt; last_1(s.size()+1,0); for(int i = 0;i\u0026lt;s.size();i++){ pre_0[i+1] = pre_0[i]; if(i%2==0\u0026amp;\u0026amp;s[i]!=\u0026#39;0\u0026#39;) pre_0[i+1]++; if(i%2==1\u0026amp;\u0026amp;s[i]!=\u0026#39;1\u0026#39;) pre_0[i+1]++; pre_1[i+1] = pre_1[i]; if(i%2==0\u0026amp;\u0026amp;s[i]!=\u0026#39;1\u0026#39;) pre_1[i+1]++; if(i%2==1\u0026amp;\u0026amp;s[i]!=\u0026#39;0\u0026#39;) pre_1[i+1]++; } int dao_yi = (s.size()-1)%2; int dao_er = dao_yi==0?1:0; for(int i=s.size()-1;i\u0026gt;=0;i--){ last_0[i] = last_0[i+1]; if(i%2==dao_yi\u0026amp;\u0026amp;s[i]!=\u0026#39;0\u0026#39;) last_0[i]++; if(i%2==dao_er\u0026amp;\u0026amp;s[i]!=\u0026#39;1\u0026#39;) last_0[i]++; last_1[i] = last_1[i+1]; if(i%2==dao_yi\u0026amp;\u0026amp;s[i]!=\u0026#39;1\u0026#39;) last_1[i]++; if(i%2==dao_er\u0026amp;\u0026amp;s[i]!=\u0026#39;0\u0026#39;) last_1[i]++; } int res = s.size(); for(int i = 0;i\u0026lt;s.size();i++){ res = min(res,pre_0[i+1]+last_1[i+1]); res = min(res,pre_1[i+1]+last_0[i+1]); } return res; } }; 传统滑窗\r基于上面的拼接思想，可以直接拼接两个字符串然后滑窗。\n一个字符串 改为 ’01‘ 的操作次数位 count 那么改为 ’10‘的操作次数一定是 len-count。\n这里使用了 static_cast\u0026lt;int\u0026gt; ，是因为 size() 函数返回无符号数做减法再转为int型可能导致错误。较好的做法是 在一开始就定义 int n = s.size()，后续使用 int型 n。\nclass Solution { public: int minFlips(string s) { int left = 0; int res = s.size(); string pattern = \u0026#34;01\u0026#34;; int count = 0; for(int i = 0;i\u0026lt;=0+static_cast\u0026lt;int\u0026gt;(s.size())-1-1;i++){ count+=s[i]!=pattern[i%2]; } for(int i = s.size()-1;i\u0026lt;=2*s.size()-1;i++){ count+=s[i%s.size()]!=pattern[i%2]; res = min(res,min(count,static_cast\u0026lt;int\u0026gt;(s.size())-count)); count-=s[i-s.size()+1]!=pattern[(i-s.size()+1)%2]; } return res; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-1888-%E4%BD%BF%E4%BA%8C%E8%BF%9B%E5%88%B6%E5%AD%97%E7%AC%A6%E4%B8%B2%E4%BA%A4%E6%9B%BF%E7%9A%84%E6%9C%80%E5%B0%91%E5%8F%8D%E8%BD%AC%E6%AC%A1%E6%95%B0/","title":"LeetCode 1888 使二进制字符串交替的最少反转次数"},{"content":"LeetCode 189 轮转数组\r🟠 中等偏上 https://leetcode.cn/problems/rotate-array/description/\n环式更新\r每次拿起一个数，递归式更新数组。\n这个过程可能两次就结束了，例如 [-1，-100，3，99]\nclass Solution { public: void rotate(vector\u0026lt;int\u0026gt;\u0026amp; nums, int k) { int n = nums.size(); int i = 0; int count = 0; while (count \u0026lt; n - 1) { int index = i; int i_num = nums[i]; nums[i] = -1; //递归式while 递归函数输入是上面 index 和i_num，nums[i] = -1 设置初始状态。 //递归函数 每次将 index处的数值 i_num 放置到下一个位置，并递归式调用下一次（这里是循环即在最后更新递归函数输入） while (true) { int next_index = (index + k)%n; int next_num = nums[next_index]; if (nums[next_index] == -1) { nums[next_index] = i_num; count++; break; } else { nums[next_index] = i_num; count++; //更新递归函数输入 index = next_index; i_num = next_num; } } i++; } } }; 巧妙反转\rhttps://leetcode.cn/problems/rotate-array/solutions/551039/xuan-zhuan-shu-zu-by-leetcode-solution-nipk/\n","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-189-%E8%BD%AE%E8%BD%AC%E6%95%B0%E7%BB%84/","title":"LeetCode 189 轮转数组"},{"content":"LeetCode 198 打家劫舍\rhttps://leetcode.cn/problems/house-robber/description/\n普通递归\r对于一维数组，子问题可以分解为 长度更小的问题，如何分解呢？可以从第一个或最后一个开始分解。\n对于本问题输入数组长度为 len 是否可以分解为长度为 len-1，len-2，len-3 这种长度小的问题呢？\n可以关心最后一个（或第一个），这个打劫 那么调用下层 len -2 ，这个不打那么调用下层 len -1\nclass Solution { public: int robb(vector\u0026lt;int\u0026gt;\u0026amp; nums,int end){ if(end == 0) return nums[0]; if(end==1) return max(nums[0],nums[1]); return max(robb(nums,end-1),nums[end]+robb(nums,end-2)); } int rob(vector\u0026lt;int\u0026gt;\u0026amp; nums) { return robb(nums,nums.size()-1); } }; 但是输入数组一多就超时了。\n记忆化递归\r注意到有些 子树 重复了，这些可以记录下来，下次再求值的时候就直接用。\nclass Solution { public: vector\u0026lt;int\u0026gt; res; int robb(vector\u0026lt;int\u0026gt;\u0026amp; nums,int end){ if(res[end]!=-1) return res[end]; if(end == 0) return nums[0]; if(end==1) return max(nums[0],nums[1]); res[end] = max(robb(nums,end-1),nums[end]+robb(nums,end-2)); return res[end]; } int rob(vector\u0026lt;int\u0026gt;\u0026amp; nums) { res = vector\u0026lt;int\u0026gt;(nums.size(),-1); return robb(nums,nums.size()-1); } }; 记忆化递归-\u0026gt;动态规划\rdfs 就是上面的递归函数。f 数组可以看成递归函数的返回值数组。\nclass Solution { public: int rob(vector\u0026lt;int\u0026gt;\u0026amp; nums) { vector\u0026lt;int\u0026gt; fun_res(nums.size(),-1); fun_res[0] = nums[0]; fun_res[1] = max(nums[0],nums[1]); for(int i =2;i\u0026lt;nums.size();i++){ fun_res[i] = max(fun_res[i-1],fun_res[i-2]+nums[i]); } return fun_res[nums.size()-1]; } }; 但是不完美，有些数组只有一个值就会报错，可以处理一下数组下标为负数的情况。\nclass Solution { public: int rob(vector\u0026lt;int\u0026gt;\u0026amp; nums) { vector\u0026lt;int\u0026gt; fun_res(nums.size(),-1); fun_res[0] = nums[0]; // fun_res[1] = max(nums[0],nums[1]); for(int i =1;i\u0026lt;nums.size();i++){ fun_res[i] = max(fun_res[i-1],(i-2\u0026gt;=0?fun_res[i-2]:0)+nums[i]); } return fun_res.back(); } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-198-%E6%89%93%E5%AE%B6%E5%8A%AB%E8%88%8D/","title":"LeetCode 198 打家劫舍"},{"content":"LeetCode 2024 考试的最大困扰度\r🟡 中等 https://leetcode.cn/problems/maximize-the-confusion-of-an-exam/description/\n思路\r窗口内是换完的答案；窗口的长度没有限制，窗口内的元素有限制。\n如果要换成 T 那么窗口内的 F个数就要小于k；如果换成 F 那么窗口内的 T 个数就要小于 k；显然可以遍历两次，一次是T 一次是F，是否可以一次过呢？可以，用或：窗口内的T或F小于k，兼备两种情况。\n这里的while 外是小于的情况，while内是大于的情况，外面是或，里面是两个否定要用 \u0026amp;\u0026amp; 。\nclass Solution { public: int maxConsecutiveAnswers(string answerKey, int k) { int T_count = 0; int F_count = 0; int left = 0; int res = 0; for(int i=0;i\u0026lt;answerKey.size();i++){ if(answerKey[i]==\u0026#39;T\u0026#39;) T_count++; if(answerKey[i]==\u0026#39;F\u0026#39;) F_count++; while(T_count\u0026gt;k\u0026amp;\u0026amp;F_count\u0026gt;k){ if(answerKey[left]==\u0026#39;T\u0026#39;){ T_count--; }else{ F_count--; } left++; } res = max(res,i-left+1); } return res; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-2024-%E8%80%83%E8%AF%95%E7%9A%84%E6%9C%80%E5%A4%A7%E5%9B%B0%E6%89%B0%E5%BA%A6/","title":"LeetCode 2024 考试的最大困扰度"},{"content":"LeetCode 206 反转链表 - 递归解法\r构造 json，显然key 是每个节点. // 先是直接返回的, 找到特例: 5-\u0026gt;null 返回还是 5-\u0026gt;null 5-\u0026gt;null:5-\u0026gt;null, //直接返回的 3-\u0026gt;4: 更深层次的调用, 2-\u0026gt;3: 更深层次的调用 如何进行下一层调用. 对于3-\u0026gt;4，reverse(4)返回逆转 以4开头的单链表的头节点，那么reverse(3) 也就是本层调用的返回值一定和reverse(4)有联系，要不然怎么依赖下一层调用，reverse(4) 返回的单链表最后接本层的节点3 就是reverse(3)的返回值, /** * Definition for singly-linked list. * struct ListNode { * int val; * ListNode *next; * ListNode() : val(0), next(nullptr) {} * ListNode(int x) : val(x), next(nullptr) {} * ListNode(int x, ListNode *next) : val(x), next(next) {} * }; */ class Solution { public: ListNode* reverseList(ListNode* head) { //一般正常输入是不会传入null的, 这个判断只是迎合题目的测试用例 if(head==nullptr){ return head; } if(head-\u0026gt;next ==nullptr){ return head; } ListNode* last = reverseList(head-\u0026gt;next); head-\u0026gt;next-\u0026gt;next = head; head-\u0026gt;next =nullptr; return last; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-206-%E5%8F%8D%E8%BD%AC%E9%93%BE%E8%A1%A8-%E9%80%92%E5%BD%92%E8%A7%A3%E6%B3%95/","title":"LeetCode 206 反转链表 - 递归解法"},{"content":"LeetCode 209 长度最小的子数组\r🟡 中等\nhttps://leetcode.cn/problems/minimum-size-subarray-sum/description/\n左程云 好理解。sum维护一个大于target 的小值，滑动左端点后再记录答案。\nclass Solution { public: int minSubArrayLen(int target, vector\u0026lt;int\u0026gt;\u0026amp; nums) { int sum = 0; int left = 0; int res = INT_MAX; for (int right = 0; right \u0026lt; nums.size(); right++) { sum += nums[right]; //确保减掉之后还满足条件 while (sum - nums[left] \u0026gt;= target) { //剪掉左边还能达标，就左移 sum -= nums[left]; left++; } if (sum \u0026gt;= target) { res = min(res, right - left + 1); } } return res==INT_MAX?0:res; } }; 灵神讲解\nsum是维护一个小于target的值。当sum大于target时（也就是满足条件）就右移左端点。\nwhile 不保证剪掉之后还满不满足条件，但是到最后停止的位置一定是不满足的，也就是sum小于target。\n这里要先记录答案再右移。\nclass Solution { public: int minSubArrayLen(int target, vector\u0026lt;int\u0026gt;\u0026amp; nums) { int sum =0; int right = nums.size()-1; int ans = nums.size()+1; //枚举起始位置,也可以枚举终止位置 for(int start = nums.size()-1;start\u0026gt;=0;start--){ sum+=nums[start]; // while(sum\u0026gt;=target){ ans = min(ans,right-start+1); sum-=nums[right]; right--; } } return ans==nums.size()+1?0:ans; } }; 枚举开始， 要从右边开始到左边。\nclass Solution { public: int minSubArrayLen(int target, vector\u0026lt;int\u0026gt;\u0026amp; nums) { int sum =0; int right = nums.size()-1; int ans = nums.size()+1; for(int start = nums.size()-1;start\u0026gt;=0;start--){ sum+=nums[start]; while(sum\u0026gt;=target){ ans = min(ans,right-start+1); sum-=nums[right]; right--; } } return ans==nums.size()+1?0:ans; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-209-%E9%95%BF%E5%BA%A6%E6%9C%80%E5%B0%8F%E7%9A%84%E5%AD%90%E6%95%B0%E7%BB%84/","title":"LeetCode 209 长度最小的子数组"},{"content":"LeetCode 2134 最少交换次数来组合所有的 1\r🟡 中等 https://leetcode.cn/problems/minimum-swaps-to-group-all-1s-together-ii/description/\n逆天思路\r百思不得其解。最后看答案：可以先计算数组中的所有1的和，确定一个窗口的大小就是数组的和，然后滑动窗口内的0一定要被换掉，我们不需要直到和谁换但一定要换，因此可以得到所有情况的交换情况。\n代码\rclass Solution { public: int minSwaps(vector\u0026lt;int\u0026gt;\u0026amp; nums) { int sum = accumulate(nums.begin(),nums.end(),0); if(sum==0) return 0; int window_sum = 0; for(int i = 0;i\u0026lt;=sum-2;i++){ window_sum +=nums[i]; } int res = nums.size(); for(int i = sum-1;i\u0026lt;=nums.size()+sum-2;i++){ window_sum+=nums[i%nums.size()]; res = min(res,sum-window_sum); window_sum-=nums[i-sum+1]; } return res; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-2134-%E6%9C%80%E5%B0%91%E4%BA%A4%E6%8D%A2%E6%AC%A1%E6%95%B0%E6%9D%A5%E7%BB%84%E5%90%88%E6%89%80%E6%9C%89%E7%9A%84-1/","title":"LeetCode 2134 最少交换次数来组合所有的 1"},{"content":"LeetCode 239 滑动窗口最大值\r🟡 中等 https://leetcode.cn/problems/sliding-window-maximum/description/\n使用单调队列。 https://www.bilibili.com/video/BV1bM411X72E/?spm_id_from=333.337.search-card.all.click\u0026vd_source=a9a24992f7f570a16d5a331e8fed9f0d\n因为左侧是按序淘汰的，因此使用队列来维护一个数组记录可能成为最大值的数\n对于 2 1 4 这三个数，当4 进入时，2 1 就不可能成为最大值了，将 2 1 从队列中剔除。\n队列维护的是候选人。\nclass Solution { public: vector\u0026lt;int\u0026gt; maxSlidingWindow(vector\u0026lt;int\u0026gt;\u0026amp; nums, int k) { deque\u0026lt;int\u0026gt; max_q; int left = 0; vector\u0026lt;int\u0026gt; res; for(int i = 0;i\u0026lt;nums.size();i++){ while(!max_q.empty()\u0026amp;\u0026amp;nums[max_q.back()]\u0026lt;=nums[i]) max_q.pop_back(); max_q.push_back(i); if(i\u0026gt;=k-1){ res.push_back(nums[max_q.front()]); if(max_q.front()==left) max_q.pop_front(); //每次入后，出时left 未必在队头。 left++; } } return res; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-239-%E6%BB%91%E5%8A%A8%E7%AA%97%E5%8F%A3%E6%9C%80%E5%A4%A7%E5%80%BC/","title":"LeetCode 239 滑动窗口最大值"},{"content":"LeetCode 2401 最长优雅子数组\r🟡 中等 https://leetcode.cn/problems/longest-nice-subarray/description/\nhttps://leetcode.cn/problems/longest-nice-subarray/solutions/2858719/bu-yong-yi-huo-by-fighting13-y5vo/\nclass Solution { public: int longestNiceSubarray(vector\u0026lt;int\u0026gt;\u0026amp; nums) { int res = 1; int left =0; int pre_sum = nums[0]; for (int i = 1; i \u0026lt; nums.size(); i++) { int c = nums[i]\u0026amp;pre_sum; if(c==0){ pre_sum+=nums[i]; }else{ while(left\u0026lt;=i-1){ pre_sum-=nums[left]; left++; if((nums[i]\u0026amp;pre_sum)==0){ break; } } pre_sum+=nums[i]; } res = max(res,i-left+1); } return res; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-2401-%E6%9C%80%E9%95%BF%E4%BC%98%E9%9B%85%E5%AD%90%E6%95%B0%E7%BB%84/","title":"LeetCode 2401 最长优雅子数组"},{"content":"LeetCode 2461 长度为 K 子数组最大和\r🟡 中等\nhttps://leetcode.cn/problems/maximum-sum-of-distinct-subarrays-with-length-k/description/\n使用 hash_map 记录 value:count，\n直接使用 hash_map[nums[i]]++; 完成插入和递增操作。\nclass Solution { public: long long maximumSubarraySum(vector\u0026lt;int\u0026gt;\u0026amp; nums, int k) { if (k \u0026gt; nums.size()) return 0; unordered_map\u0026lt;int, int\u0026gt; hash_map; // 用于存储元素及其出现次数 long long sum = 0; long long max_sum = 0; // 初始化窗口 for (int i = 0; i \u0026lt; k; i++) { hash_map[nums[i]]++; sum += nums[i]; } // 检查初始化窗口 if (hash_map.size() == k) { max_sum = sum; } // 滑动窗口 for (int i = k; i \u0026lt; nums.size(); i++) { // 新元素进入窗口 hash_map[nums[i]]++; sum += nums[i]; // 最左边元素出窗口 hash_map[nums[i - k]]--; sum -= nums[i - k]; if (hash_map[nums[i - k]] == 0) { hash_map.erase(nums[i - k]); } // 检查当前窗口 if (hash_map.size() == k) { max_sum = max(max_sum, sum); } } return max_sum; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-2461-%E9%95%BF%E5%BA%A6%E4%B8%BA-k-%E5%AD%90%E6%95%B0%E7%BB%84%E6%9C%80%E5%A4%A7%E5%92%8C/","title":"LeetCode 2461 长度为 K 子数组最大和"},{"content":"LeetCode 26 删除有序数组中的重复项\r🟡 中等\nhttps://leetcode.cn/problems/remove-duplicates-from-sorted-array-ii/description/\n思路\r左侧指针 left 为写入位置，右侧指针 right 向右遍历。 pre记录上一位的值，count 记录当前pre 的count。\nright 每次向右移动一位 nums[right] 和 pre 比较 相同 检查 count 不同 count 重置为1 class Solution { public: int removeDuplicates(vector\u0026lt;int\u0026gt;\u0026amp; nums) { int left = 0,right = 0;//left next loc; int n = nums.size(); int left_count = 0; int pre = 10001; while(right\u0026lt;n){ if(nums[right]==pre){ if(left_count==2){ }else{ nums[left] = nums[right]; pre = nums[left]; left_count++; left++; } }else{ nums[left] = nums[right]; pre = nums[left]; left_count = 1; left++; } right++; } cout\u0026lt;\u0026lt;left\u0026lt;\u0026lt;endl; return left - 0+1-1; } }; 可以优化\nclass Solution { public: int removeDuplicates(vector\u0026lt;int\u0026gt;\u0026amp; nums) { int n = nums.size(); if(n\u0026lt;3) return n; int slow = 2,fast = 2; while(fast\u0026lt;n){ if(nums[slow-2]!=nums[fast]){ nums[slow] = nums[fast]; slow++; } fast++; } return slow-1-0+1; } }; java 版本 按照栈的思路， 当前元素 如果和栈下面的第二个元素不等， 那么可以入栈 class Solution { public int removeDuplicates(int[] nums) { int stackCursor = 2; int fast = 2; while (fast\u0026lt;=nums.length-1){ if(nums[fast]!=nums[stackCursor-2]){ nums[stackCursor++] = nums[fast]; } fast++; } return Math.min(stackCursor-1-0+1,nums.length); } } ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-26-%E5%88%A0%E9%99%A4%E6%9C%89%E5%BA%8F%E6%95%B0%E7%BB%84%E4%B8%AD%E7%9A%84%E9%87%8D%E5%A4%8D%E9%A1%B9/","title":"LeetCode 26 删除有序数组中的重复项"},{"content":"LeetCode 2653 滑动子数组的美丽值\r🟡中等\n关键在于如何找到 topk的值。\n可以使用 hash_map 来解决\nclass Solution { public: int get_k(unordered_map\u0026lt;int,int\u0026gt;\u0026amp; hash_map, int k){ int sum = 0; for(int i = -50;i\u0026lt;0;i++){ if(hash_map[i]\u0026gt;0){ sum+=hash_map[i]; if(sum\u0026gt;=k){ return i; } } } return 0; } vector\u0026lt;int\u0026gt; getSubarrayBeauty(vector\u0026lt;int\u0026gt;\u0026amp; nums, int k, int x) { unordered_map\u0026lt;int,int\u0026gt; hash_map; for(int i =0;i\u0026lt;k-1;i++){ hash_map[nums[i]]++; } // int beauty = get_k(hash_map,x); vector\u0026lt;int\u0026gt; res; // res.push_back(beauty); for(int i = k-1;i\u0026lt;nums.size();i++){ hash_map[nums[i]]++; // if(hash_map[nums[i-k]]==0){ // hash_map.erase(nums[i-k]); // } res.push_back(get_k(hash_map,x)); hash_map[nums[i-k+1]]--; } return res; } }; 优化后的版本\n作个映射，-50-50映射到 0-100 ，不用 hash_map 存值， 直接使用数组即可。\nclass Solution { public: vector\u0026lt;int\u0026gt; getSubarrayBeauty(vector\u0026lt;int\u0026gt;\u0026amp; nums, int k, int x) { int count[101]; for(int i =0;i\u0026lt;k-1;i++){ count[nums[i]+50]++; } vector\u0026lt;int\u0026gt; res; for(int i = k-1;i\u0026lt;nums.size();i++){ count[nums[i]+50]++; int rr = 0; int sum = 0; for(int j = -50;j\u0026lt;0;j++){ sum+=count[j+50]; if(sum\u0026gt;=x) { rr = j; break; } } res.push_back(rr); count[nums[i-k+1]+50]--; } return res; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-2653-%E6%BB%91%E5%8A%A8%E5%AD%90%E6%95%B0%E7%BB%84%E7%9A%84%E7%BE%8E%E4%B8%BD%E5%80%BC/","title":"LeetCode 2653 滑动子数组的美丽值"},{"content":"LeetCode 279 完全平方数\r🟡 中等\nhttps://leetcode.cn/problems/perfect-squares/description/\n注意到 1 \u0026lt;= n \u0026lt;= 10^4，这就类似于换银币[11-518零钱兑换](LeetCode 518 零钱兑换 II.md) ，只不过这里的硬币是一系列的平方数。\nclass Solution { public: int sel_not(int i, int cap) { if (i == 0) { if (cap == 0) return 0; return INT_MAX; } int ii = i * i; int not_sel = sel_not(i - 1, cap); if (cap \u0026gt;= ii) { int sel = sel_not(i, cap - ii); int left = sel == INT_MAX ? INT_MAX : 1 + sel; return min(left, not_sel); } else { return not_sel; } } int numSquares(int n) { int dp[n + 1]; fill(dp, dp + n + 1, INT_MAX); dp[0] = 0; for (int i = 1; i \u0026lt;= 100; i++) { for (int j = i * i; j \u0026lt;= n; j++) { int sel = dp[j - i*i]; int left = sel == INT_MAX ? INT_MAX : 1 + sel; dp[j] = min(left, dp[j]); } } // return sel_not(100, n); return dp[n]; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-279-%E5%AE%8C%E5%85%A8%E5%B9%B3%E6%96%B9%E6%95%B0/","title":"LeetCode 279 完全平方数"},{"content":"LeetCode 2831 找出最长等值子数组\r🟠 中等偏上\nhttps://leetcode.cn/problems/find-the-longest-equal-subarray/description/\n思路\n先遍历一遍，得到相同的数的位置，再在这些位置上进行滑动窗口。\n讲解\nclass Solution { public: int longestEqualSubarray(vector\u0026lt;int\u0026gt;\u0026amp; nums, int k) { unordered_map\u0026lt;int,vector\u0026lt;int\u0026gt;\u0026gt; Map; for(int i=0;i\u0026lt;nums.size();i++){ Map[nums[i]].push_back(i); } int res = 0; for(auto [key,pos]:Map){ int left = 0; for(int i = 0;i\u0026lt;pos.size();i++){ while(i\u0026lt;pos.size()\u0026amp;\u0026amp; (pos[i]-pos[left]+1)-(i-left+1)\u0026gt;k){ left++; } res = max(i-left+1,res); } } return res; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-2831-%E6%89%BE%E5%87%BA%E6%9C%80%E9%95%BF%E7%AD%89%E5%80%BC%E5%AD%90%E6%95%B0%E7%BB%84/","title":"LeetCode 2831 找出最长等值子数组"},{"content":"LeetCode 287 寻找重复数\r🟠 中等偏上\nhttps://leetcode.cn/problems/find-the-duplicate-number/description/\n看成循环链表\rclass Solution { public: int findDuplicate(vector\u0026lt;int\u0026gt;\u0026amp; nums) { // 下标是本节点的地址，也是val，nums[i] 存的是下一个数的地址（下标） int slow = 0; int fast = 0; while(true){ slow = nums[slow]; fast = nums[nums[fast]]; if(slow == fast){ break; } } fast = 0; while(fast!=slow){ slow = nums[slow]; fast = nums[fast]; } return slow; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-287-%E5%AF%BB%E6%89%BE%E9%87%8D%E5%A4%8D%E6%95%B0/","title":"LeetCode 287 寻找重复数"},{"content":"LeetCode 2962 统计最大元素出现至少 K 次的子数组\r🟠 中等偏上 https://leetcode.cn/problems/count-subarrays-where-max-element-appears-at-least-k-times/description/\n思路\r永远不要忘记分类的思想， 可以按右边结尾分类，那么以 i 结尾的合法子数组中一定包括k个最大值，左边的任意一个结束位置都是合法的。这样左边的界限是递增的，右边也是，时间复杂度：O(n)。\n也可以以左边为分类标准，找到合法的位置后右边的每个位置都是合法的。 下面是以左分类，所以答案更新在while里面。 class Solution { public: long long countSubarrays(vector\u0026lt;int\u0026gt;\u0026amp; nums, int k) { int max_one = INT_MIN; for(int val:nums){ max_one = max(max_one,val); } long left = 0; int count = 0; long res = 0; for(int i = 0;i\u0026lt;nums.size();i++){ if(nums[i]==max_one){ count++; } while(count\u0026gt;=k){ res+=nums.size()-i; if(nums[left]==max_one){ count--; } left++; } } return res; } }; 上面是 左端点为left，右端点是数组结尾。也可以左边是数组开始，右边是i。\nclass Solution { public: long long countSubarrays(vector\u0026lt;int\u0026gt;\u0026amp; nums, int k) { int max_one = INT_MIN; for(int val:nums){ max_one = max(max_one,val); } long left = 0; int count = 0; long res = 0; for(int i = 0;i\u0026lt;nums.size();i++){ if(nums[i]==max_one){ count++; } while(count\u0026gt;=k){ if(nums[left]==max_one){ count--; } left++; } res+=left; } return res; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-2962-%E7%BB%9F%E8%AE%A1%E6%9C%80%E5%A4%A7%E5%85%83%E7%B4%A0%E5%87%BA%E7%8E%B0%E8%87%B3%E5%B0%91-k-%E6%AC%A1%E7%9A%84%E5%AD%90%E6%95%B0%E7%BB%84/","title":"LeetCode 2962 统计最大元素出现至少 K 次的子数组"},{"content":"LeetCode 3 无重复字符的最长子串\r🟡 中等\nhttps://leetcode.cn/problems/longest-substring-without-repeating-characters/description/\n先给答案分类， 再加上最优性质， 再思考之间的关系.\ns = \u0026ldquo;abcabcbb\u0026rdquo;\n以c结尾的和前面以b结尾的有什么关系? 显然以b结尾的最长子串加上c之后可能会出现重复， 一定是和c发生重复， 所以移动左端点， 直到不重复。\n如何判断重复. 可以使用hash表， char:count， hash表的大小等于 左右之间的个数时就是不重复， 否则重复了， 移动左端点直到 hash大小等于 左右之间的长度。 class Solution { public: int lengthOfLongestSubstring(string s) { unordered_map\u0026lt;char,int\u0026gt; hash_map; int left = 0; int ans =0; for(int right = 0;right\u0026lt;s.size();right++){ hash_map[s[right]]++; while(hash_map.size()\u0026lt;(right-left+1)){ hash_map[s[left]]--; if(hash_map[s[left]]==0) hash_map.erase(s[left]); left++; } // cout\u0026lt;\u0026lt;hash_map.size(); // ans = max(ans,static_cast\u0026lt;int\u0026gt;(hash_map.size())); } return ans; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-3-%E6%97%A0%E9%87%8D%E5%A4%8D%E5%AD%97%E7%AC%A6%E7%9A%84%E6%9C%80%E9%95%BF%E5%AD%90%E4%B8%B2/","title":"LeetCode 3 无重复字符的最长子串"},{"content":"LeetCode 31 下一个排列\r🟠 中等偏上 https://leetcode.cn/problems/next-permutation/description/\n思路\r数变大一定是大的数前移了。 如果是很大的数前移，整体数值的确变大，但是不是下一个排列，因此，要刚好大一点的数前移。\nclass Solution { public: void nextPermutation(vector\u0026lt;int\u0026gt;\u0026amp; nums) { int n = nums.size(); int right = n-2; while(right\u0026gt;=0\u0026amp;\u0026amp;nums[right]\u0026gt;=nums[right+1]){ right--; } if(right==-1){ sort(nums.begin(),nums.end()); }else{ int i = right+1; int small_bigger_i = i; while(i\u0026lt;n){ if(nums[i]\u0026gt;nums[right]\u0026amp;\u0026amp;nums[i]\u0026lt;nums[small_bigger_i]){ small_bigger_i = i; } i++; } swap(nums[right],nums[small_bigger_i]); sort(nums.begin()+right+1,nums.end()); cout\u0026lt;\u0026lt;small_bigger_i; } cout\u0026lt;\u0026lt;right\u0026lt;\u0026lt;endl; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-31-%E4%B8%8B%E4%B8%80%E4%B8%AA%E6%8E%92%E5%88%97/","title":"LeetCode 31 下一个排列"},{"content":"LeetCode 343 整数分割\r🟡 中等\nhttps://leetcode.cn/problems/integer-break/\n递归\r首先思考递归，如果递归函数语义是问题本身，那么与子问题的联系为：本层的返回值=分出来的一个数*右边的数分割得到的最大乘积。\nclass Solution { public: int integerBreak(int n) { if(n\u0026lt;=2) return 1; int max_ret = 0; for(int i = 1;i\u0026lt;n;i++){ max_ret = max(max_ret,i*integerBreak(n-i)); } return max_ret; } }; 但是这是错误的，因为右边可以不分，这样可能会最大，例如：如果右边为3，此时右边不分最大，如果分最大为1*2 = 2，结果倒小了。\n所以判断一下右边是分了大还是不分大。\nclass Solution { public: int integerBreak(int n) { if(n\u0026lt;=2) return 1; int max_ret = 0; for(int i = 1;i\u0026lt;n;i++){ int right =integerBreak(n-i)\u0026gt;n-i?integerBreak(n-i):n-i; max_ret = max(max_ret,i*right); } return max_ret; } }; 动态规划\rclass Solution { public: // if (n \u0026lt;= 2) // return 1; // int max_ret = 0; // for (int i = 1; i \u0026lt; n; i++) { // int right = integerBreak(n - i) \u0026gt; n - i ? integerBreak(n - i) : n - i; // max_ret = max(max_ret, i * right); // } // return max_ret; int integerBreak(int n) { int dp[n+1]; memset(dp,0,sizeof dp); dp[1] = 1; dp[2] = 1; for(int i = 3;i\u0026lt;=n;i++){ for(int left =1;left\u0026lt;=i-1;left++ ){ int right = max(dp[i-left],i-left); dp[i] = max(dp[i],left*right); } } return dp[n]; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-343-%E6%95%B4%E6%95%B0%E5%88%86%E5%89%B2/","title":"LeetCode 343 整数分割"},{"content":"LeetCode 37 解数独 - 回溯解法\rhttps://leetcode.cn/problems/sudoku-solver/\n思路\r一个一个填，而填每个时要检查1-9的合法性（检查合法较为麻烦），然后递归的调用。\n错误\rfor(int i =1;i\u0026lt;=9;i++){ if(check(board,row,col,i)){ board[row][col] = i write(....) 下一层调用 board[row][col] = \u0026#39;.\u0026#39; } } 这样是有问题的，到最后没有修改board的值，因为执行完 write 函数后，立即会又改为 . ，这样到最后 board 又被修改回去了，错误的代码如下：\nclass Solution { public: bool check(vector\u0026lt;vector\u0026lt;char\u0026gt;\u0026gt;\u0026amp; board, int row, int col, int num) { for (int i = 0; i \u0026lt; 9; i++) { // 行：row 列：i if (board[row][i] - \u0026#39;0\u0026#39; == num) return false; } for (int i = 0; i \u0026lt; 9; i++) { if (board[i][col] - \u0026#39;0\u0026#39; == num) return false; } for (int i = (row / 3) * 3; i \u0026lt; (row / 3) * 3 + 3; i++) { for (int j = (col / 3) * 3; j \u0026lt; (col / 3) * 3 + 3; j++) { if (board[i][j] - \u0026#39;0\u0026#39; == num) return false; } } return true; } void write(vector\u0026lt;vector\u0026lt;char\u0026gt;\u0026gt;\u0026amp; board, int row, int col) { if (row == 9) return; if (board[row][col] == \u0026#39;.\u0026#39;) { for (int i = 1; i \u0026lt;= 9; i++) { if (check(board, row, col, i)) { board[row][col] = (char)(i + \u0026#39;0\u0026#39;); write(board, row + 1, (col + 1) % 9);//这里row和col计算不对 board[row][col] = \u0026#39;.\u0026#39;; } } } else{ write(board, row + 1, (col + 1) % 9); } } void solveSudoku(vector\u0026lt;vector\u0026lt;char\u0026gt;\u0026gt;\u0026amp; board) { write(board, 0, 0); } }; 改正\r我们要做的就是，在得到合法状态后，不要执行后面的 board[row][col] = '.'; ，所以需要 return， 这就需要下层调用向上返回是否找到合法状态\n返回 true 表示找到合法状态， 这时直接return，不执行后面的 board[row][col] = '.' 。\nfor(int i =1;i\u0026lt;=9;i++){ if(check(board,row,col,i)){ board[row][col] = i if(write(....) 下一层调用) return; board[row][col] = \u0026#39;.\u0026#39; } } 小插曲： 我的代码计算 next_row next_col 有误，正确的应该是 在列到8时 row 要加一。只用取余函数是不对的。\r代码\rclass Solution { public: bool check(vector\u0026lt;vector\u0026lt;char\u0026gt;\u0026gt;\u0026amp; board, int row, int col, int num) { for (int i = 0; i \u0026lt; 9; i++) { // 行：row 列：i if (board[row][i] - \u0026#39;0\u0026#39; == num) return false; } for (int i = 0; i \u0026lt; 9; i++) { if (board[i][col] - \u0026#39;0\u0026#39; == num) return false; } for (int i = (row / 3) * 3; i \u0026lt; (row / 3) * 3 + 3; i++) { for (int j = (col / 3) * 3; j \u0026lt; (col / 3) * 3 + 3; j++) { if (board[i][j] - \u0026#39;0\u0026#39; == num) return false; } } return true; } bool write(vector\u0026lt;vector\u0026lt;char\u0026gt;\u0026gt;\u0026amp; board, int row, int col) { if (row == 9) return true; int next_row = col==8?row+1:row; int next_col = (col + 1) % 9; if (board[row][col] == \u0026#39;.\u0026#39;) { for (int i = 1; i \u0026lt;= 9; i++) { if (check(board, row, col, i)) { board[row][col] = (char)(i + \u0026#39;0\u0026#39;); if (write(board, next_row, next_col)) { return true; }; board[row][col] = \u0026#39;.\u0026#39;; } } return false; } else { return write(board, next_row, next_col); } } void solveSudoku(vector\u0026lt;vector\u0026lt;char\u0026gt;\u0026gt;\u0026amp; board) { write(board, 0, 0); } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-37-%E8%A7%A3%E6%95%B0%E7%8B%AC-%E5%9B%9E%E6%BA%AF%E8%A7%A3%E6%B3%95/","title":"LeetCode 37 解数独 - 回溯解法"},{"content":"LeetCode 376 摆动序列 - 贪心解法\rhttps://leetcode.cn/problems/wiggle-subsequence/description/\n思路\r直觉上可以看某个点是不是 V 或 ^，但这种写法很难处理“平坡”。 实际上，是否计数取决于 方向是否发生变化。\n如果前后都是上升（中间即使有平坡），就没有新拐点；如果从上升变为下降（或反过来），就产生一个拐点。 因此，只需要关注“非平坡方向”的变化。统计的是 有效上升/下降区间数量，最后答案是“区间数 + 1”。\n代码\r这里 pre_direction 只有初始为 0，后续一旦遇到非平坡差值就会更新为正或负。\nclass Solution { public: int wiggleMaxLength(vector\u0026lt;int\u0026gt;\u0026amp; nums) { if(nums.size()\u0026lt;=1) return nums.size(); int res = 0; int pre_direction =0; for(int i = 1;i\u0026lt;nums.size();i++){ int direction = nums[i] -nums[i-1]; if(direction!=0){ if(direction\u0026gt;0\u0026amp;\u0026amp;pre_direction\u0026lt;=0) res++; if(direction\u0026lt;0\u0026amp;\u0026amp;pre_direction\u0026gt;=0) res++; pre_direction = direction; } } return res+1; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-376-%E6%91%86%E5%8A%A8%E5%BA%8F%E5%88%97-%E8%B4%AA%E5%BF%83%E8%A7%A3%E6%B3%95/","title":"LeetCode 376 摆动序列 - 贪心解法"},{"content":"LeetCode 39 组合总和 - 回溯解法\rhttps://leetcode.cn/problems/combination-sum/description/\n思路\r类似于[组合](LeetCode 77 组合 - 回溯解法.md)，这里要求路径总和等于 target。\n手动模拟\rcandidates = [2,3,6,7]，target = 7。 先把 2 放入 path，下一层仍然可以从 2, 3, 6, 7 里选。这和[组合](LeetCode 77 组合 - 回溯解法.md)不同：本题允许重复选同一个数，所以可能连续选择同一元素。用 sum 控制递归边界，sum \u0026gt; target 时直接返回。\n两部分\r确定函数输入： start：开始下标。 end：结束下标。 sum：当前 path 总和。 target：目标总和。\n直接返回。 sum==target 保存结果，返回 sum\u0026gt;target 不保存，返回 进一步调用。 函数语义：从 start 到 end 中选一个数。 为了遍历所有情况，需要 for 循环。i 是本层选中的数，下一层仍然从 i 开始（允许重复选择当前数）。 伪代码\rfunction sel_num(start, end, sum, target){ if(sum==target) 保存 return //为了防止无限的加入 if(sum\u0026gt; target) 不保存 return for(i, i\u0026lt;=end, i++){ path.push_back(shuzu[i]) // 下一层仍然允许从当前 i 开始（可重复），但 sum 需要更新。 sel_num(start, end, sum+shuzu[i], target) path.pop_back() } } 代码\rclass Solution { public: vector\u0026lt;int\u0026gt; path; vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt; result; //存储数组, 不然sel_number拿不到 candidate数组 vector\u0026lt;int\u0026gt; loc_candidates; void sel_number(int start, int end, int current_sum, int target) { if (current_sum == target) { result.push_back(path); return; } //不保存, 直接返回 if(current_sum \u0026gt; target) return; for (int i = start; i \u0026lt;= end; i++) { path.push_back(loc_candidates[i]); //sum 要及时更新 sel_number(i, end, current_sum + loc_candidates[i], target); path.pop_back(); } } vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt; combinationSum(vector\u0026lt;int\u0026gt;\u0026amp; candidates, int target) { loc_candidates = candidates; sel_number(0,candidates.size()-1, 0, target); return result; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-39-%E7%BB%84%E5%90%88%E6%80%BB%E5%92%8C-%E5%9B%9E%E6%BA%AF%E8%A7%A3%E6%B3%95/","title":"LeetCode 39 组合总和 - 回溯解法"},{"content":"LeetCode 394 字符串解码 - 栈解法\r字符串解码\n思路\r使用两个栈同步维护状态：\nnumDeque：记录每一层括号对应的重复次数。 strDeque：记录每一层括号内已经解析出的字符串。 遍历到 ] 时，表示当前层结束：弹出当前层字符串和次数，重复拼接后并回上一层。\nclass Solution { public String decodeString(String s) { Deque\u0026lt;Integer\u0026gt; numDeque = new LinkedList\u0026lt;\u0026gt;(); Deque\u0026lt;String\u0026gt; strDeque = new LinkedList\u0026lt;\u0026gt;(); // 遍历到 ] 时 numDeque 和strDeque 表示 重复 num.top次 strD.top , numDeque.push(1); strDeque.push(\u0026#34;\u0026#34;); int i = 0; while (i \u0026lt; s.length()) { if (Character.isDigit(s.charAt(i))) { int num = 0; while (Character.isDigit(s.charAt(i))) { num = num * 10 + (s.charAt(i) - \u0026#39;0\u0026#39;); i++; } // 数字入栈, 要保证两个循环不变量的含义, str也入栈, i++, 左括号直接跳过 i++; numDeque.push(num); strDeque.push(\u0026#34;\u0026#34;); } else if (s.charAt(i) == \u0026#39;]\u0026#39;) { String cur = strDeque.pop(); int times = numDeque.pop(); StringBuilder sb = new StringBuilder(); for (int j = 0; j \u0026lt; times; j++) { sb.append(cur); } // 数字出栈了, str栈顶也要维护, strDeque.push(strDeque.pop() + sb.toString()); i++; } else { strDeque.push(strDeque.pop() + s.charAt(i)); i++; } } return strDeque.pop(); } } 心得\r遇到左括号时入栈，是为了和右括号出栈形成配对。遍历到右括号时，说明“最近一个左括号之后”的内容已经完整，可以立刻结算并拼接回上一层。数字栈和字符串栈都遵循这个层级关系。\n","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-394-%E5%AD%97%E7%AC%A6%E4%B8%B2%E8%A7%A3%E7%A0%81-%E6%A0%88%E8%A7%A3%E6%B3%95/","title":"LeetCode 394 字符串解码 - 栈解法"},{"content":"LeetCode 395 至少有 K 个重复字符的最长子串 - 分治解法\r🟠 中等偏上 https://leetcode.cn/problems/longest-substring-with-at-least-k-repeating-characters/\n思路\r这题思路不太直观，可以用滑动窗口，也可以用分治。\n分治关键在于“如何切分”：\n若某字符在当前字符串中的出现次数小于 k，它不可能出现在合法答案中。 因此可以把这些字符当作分隔符，把原串切成多个子串，分别递归求解，再取最大值。 class Solution { public: int longestSubstring(string s, int k) { unordered_map\u0026lt;char,int\u0026gt; hash_map; for(auto c:s) hash_map[c]++; vector\u0026lt;int\u0026gt; split_index; for(int i = 0;i\u0026lt;s.size();i++){ if(hash_map[s[i]]\u0026lt;k){ split_index.push_back(i); } } if(split_index.size()==0) return s.size(); int ans = 0; int left = 0; split_index.push_back(s.size()); for(int i = 0;i\u0026lt;split_index.size();i++){ int len = split_index[i]-left; if(len\u0026gt;ans) ans = max(ans,longestSubstring(s.substr(left,len),k)); left = split_index[i]+1; } return ans; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-395-%E8%87%B3%E5%B0%91%E6%9C%89-k-%E4%B8%AA%E9%87%8D%E5%A4%8D%E5%AD%97%E7%AC%A6%E7%9A%84%E6%9C%80%E9%95%BF%E5%AD%90%E4%B8%B2-%E5%88%86%E6%B2%BB%E8%A7%A3%E6%B3%95/","title":"LeetCode 395 至少有 K 个重复字符的最长子串 - 分治解法"},{"content":"LeetCode 395 至少有 K 个重复字符的最长子串 - 滑动窗口解法\r🟠 中等偏上 https://leetcode.cn/problems/longest-substring-with-at-least-k-repeating-characters/description/\n特殊的点\r长度没有限制，窗口内的元素有限制：如果用一般的思路：左侧侧何时滑动呢？因为当前窗口内可能不满足条件，如果左边往左扩展可能找到答案，往右也有可能找到答案。因此窗口的滑动规则十分模糊，那如何给窗口一个限制呢？注意到字符集是26个，可以主动限制每次滑动窗口内的字符种类个数。\n代码\rclass Solution { public: bool is_ok(unordered_map\u0026lt;char, int\u0026gt; hash_map, int k) { for (const auto\u0026amp; pair : hash_map) { if (pair.second \u0026lt; k) return false; } return true; } int longestSubstring(string s, int k) { int res = 0; //m控制窗口内的种类限制。 for (int m = 1; m \u0026lt;= 26; m++) { int left = 0; unordered_map\u0026lt;char, int\u0026gt; hash_map; for (int i = 0; i \u0026lt; s.size() \u0026amp;\u0026amp; m \u0026lt;= 26; i++) { hash_map[s[i]]++; //一开始是小于m的，因此外面是小，while内是大。 while (hash_map.size() \u0026gt; m) { hash_map[s[left]]--; if (hash_map[s[left]] == 0) { hash_map.erase(s[left]); } left++; } if (is_ok(hash_map, k)) { res = max(res, i - left + 1); } } } return res; } }; 分治\r使用递归分而治之。\n如何分：显然对于那些出现次数小于k的字符一定不可能出现在答案中，因此用这些字符分割。 class Solution { public: int longestSubstring(string s, int k) { unordered_map\u0026lt;char,int\u0026gt; hash_map; for(auto c:s) hash_map[c]++; vector\u0026lt;int\u0026gt; split_index; for(int i = 0;i\u0026lt;s.size();i++){ if(hash_map[s[i]]\u0026lt;k){ split_index.push_back(i); } } if(split_index.size()==0) return s.size(); int ans = 0; int left = 0; split_index.push_back(s.size()); for(int i = 0;i\u0026lt;split_index.size();i++){ int len = split_index[i]-left; if(len\u0026gt;ans) ans = max(ans,longestSubstring(s.substr(left,len),k)); left = split_index[i]+1; } return ans; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-395-%E8%87%B3%E5%B0%91%E6%9C%89-k-%E4%B8%AA%E9%87%8D%E5%A4%8D%E5%AD%97%E7%AC%A6%E7%9A%84%E6%9C%80%E9%95%BF%E5%AD%90%E4%B8%B2-%E6%BB%91%E5%8A%A8%E7%AA%97%E5%8F%A3%E8%A7%A3%E6%B3%95/","title":"LeetCode 395 至少有 K 个重复字符的最长子串 - 滑动窗口解法"},{"content":"LeetCode 40 组合总和 II - 回溯解法\rhttps://leetcode.cn/problems/combination-sum-ii/description/\n思路\r和 [子集2](LeetCode 90 子集 II - 回溯解法.md) 类似.\n先排序， 重点在于如何去重.\n举例: [0,1,1,2,3] target = 3; 对于第一个[1,2]是可以的， 但是第二个1开头就不应该加入到result中.\n思路1\n那么， 什么时候该跳过: 如果用candidate[i]==candate[i-1]来判断跳过, 第一个1没问题， 但是当path为[1]，for循环要遍历[1,2,3]时，这里的1就被跳过了，按道理不应该跳过， 如果1后面不是2是1 [1,1,2,3]那么第二个1应该被跳过， 因此被跳过的不能是第一个数.\n思路2:\n取第一个1后， 接着要遍历[1,2,3]. 取第二个1后要遍历[2,3]，这就重复了，所以重复是在一个for循环里， 也就是说你不可以和你兄弟相同，相同就必然会出现(组合)重复，你和你孩子相同会导致一个path中有重复元素，这是允许的， 可以设置一个变量 last初始为-1， 如果for循环取出的值等于last就应该跳过， 否则继续.\n举例:\nfor(遍历全部){ 0: 没事 -last =0; 第一个1: 没事 last = 1; 第二个1: 有事 跳掉因为他们的孩子树是相同的. } 代码\rclass Solution { public: vector\u0026lt;int\u0026gt; path; vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt; result; vector\u0026lt;int\u0026gt; loc_canditates; void sel_number(int start, int end, int sum, int target){ if(sum==target){ result.push_back(path); return; } if(sum\u0026gt;target) return; int last =-1; for(int i = start;i\u0026lt;=end;i++){ //if(i\u0026gt;start\u0026amp;\u0026amp;loc_canditates[i]==loc_canditates[i-1])continue; if(loc_canditates[i]==last) continue; last = loc_canditates[i]; path.push_back(loc_canditates[i]); sel_number(i+1,end,sum+loc_canditates[i],target); path.pop_back(); } } vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt; combinationSum2(vector\u0026lt;int\u0026gt;\u0026amp; candidates, int target) { loc_canditates = candidates; sort(loc_canditates.begin(),loc_canditates.end()); sel_number(0,candidates.size()-1, 0, target); return result; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-40-%E7%BB%84%E5%90%88%E6%80%BB%E5%92%8C-ii-%E5%9B%9E%E6%BA%AF%E8%A7%A3%E6%B3%95/","title":"LeetCode 40 组合总和 II - 回溯解法"},{"content":"LeetCode 416 分割等和子集\r🟡 中等\nhttps://leetcode.cn/problems/partition-equal-subset-sum/description/\n看到子集想到“灵神”说的“选不选的问题， 对于数组中的每个元素肯定是要选的，关键是分到哪个集合中：可以分到第一个，也可以分到第二个。\nclass Solution { public: bool add_path(vector\u0026lt;int\u0026gt;\u0026amp; nums,int index,int first,int second){ if(index==nums.size()){ if(first==second){ return true; } return false; } first+=nums[index]; if(add_path(nums,index+1,first,second)) return true; first-=nums[index]; second+=nums[index]; if(add_path(nums,index+1,first,second)) return true; second-=nums[index]; return false; } bool canPartition(vector\u0026lt;int\u0026gt;\u0026amp; nums) { return add_path(nums,0,0,0); } }; 或者 先求和，在累加看看能不能等于和的一半。\nclass Solution { public: bool add_path(vector\u0026lt;int\u0026gt;\u0026amp; nums,int index,int sum,int target){ if(sum\u0026gt;target) return false; if(sum==target) return true; if(index==nums.size()) return false; if(add_path(nums,index+1,sum+nums[index],target)) return true; if(add_path(nums,index+1,sum,target)) return true; return false; } bool canPartition(vector\u0026lt;int\u0026gt;\u0026amp; nums) { int sum = accumulate(nums.begin(),nums.end(),0); if(sum%2==1) return false; return add_path(nums,0,0,sum/2); } }; 如果要转化为dp数组，发现参数为三个数，那么应该是三维数组，但是这里看 add_path 的参数中的 sum 和 target 其实可以用他们之差 cap 作为参数，意思就是拿着cap容量的背包去装，装完也就是说cap==0了，那么就返回true 。\nclass Solution { public: bool add_path(vector\u0026lt;int\u0026gt;\u0026amp; nums,int index,int cap){ if(cap\u0026lt;0) return false; if(cap==0) return true; if(index==nums.size()) return false; if(add_path(nums,index+1,cap-nums[index])) return true; if(add_path(nums,index+1,cap)) return true; return false; } bool canPartition(vector\u0026lt;int\u0026gt;\u0026amp; nums) { int sum = accumulate(nums.begin(),nums.end(),0); if(sum%2==1) return false; return add_path(nums,0,sum/2); } }; 改为dp\n因为本层需要 （index+1，cap）和（index+1，cap-nums[i]），因此数组行数：0-numsSize，列数：0-cap； 初始化：左边一列，cap等于0，因此为true；最底下一行index超了，因此为false。 遍历顺序：行从下到上，列从左到右。 class Solution { public: bool add_path(vector\u0026lt;int\u0026gt;\u0026amp; nums,int index,int cap){ if(cap\u0026lt;0) return false; if(cap==0) return true; if(index==nums.size()) return false; if(add_path(nums,index+1,cap-nums[index])) return true; if(add_path(nums,index+1,cap)) return true; return false; } bool canPartition(vector\u0026lt;int\u0026gt;\u0026amp; nums) { int sum = accumulate(nums.begin(),nums.end(),0); if(sum%2==1) return false; bool dp[nums.size()+1][sum/2+1]; memset(dp,0,sizeof dp); for(int i = 0;i\u0026lt;nums.size();i++){ dp[i][0] = true; } for(int i = 0;i\u0026lt;=sum/2;i++){ dp[nums.size()][i] =false; } for(int i = nums.size()-1;i\u0026gt;=0;i--){ for(int j = 1;j\u0026lt;=sum/2;j++){ if(dp[i+1][j]){ dp[i][j] =true; continue; } if(j-nums[i]\u0026lt;0){ dp[i][j] = false; continue; } if(dp[i+1][j-nums[i]]){ dp[i][j] = true; } } } return dp[0][sum/2]; } }; 这里还可以进一步优化，注意到本层只依赖于下一行，因此可以只维护一行就行。\n这里注意，内层循环要倒过来了，如果从左到右：右边依靠左边的旧值就会被更新，进而导致答案错误。\nclass Solution { public: bool add_path(vector\u0026lt;int\u0026gt;\u0026amp; nums,int index,int cap){ if(cap\u0026lt;0) return false; if(cap==0) return true; if(index==nums.size()) return false; if(add_path(nums,index+1,cap-nums[index])) return true; if(add_path(nums,index+1,cap)) return true; return false; } bool canPartition(vector\u0026lt;int\u0026gt;\u0026amp; nums) { int sum = accumulate(nums.begin(),nums.end(),0); if(sum%2==1) return false; bool dp[sum/2+1]; memset(dp,false,sizeof dp); dp[0] = true; for(int i = nums.size()-1;i\u0026gt;=0;i--){ for(int j = sum/2;j\u0026gt;=1;j--){ if(dp[j]){ dp[j] =true; continue; } if(j-nums[i]\u0026lt;0){ dp[j] = false; continue; } if(dp[j-nums[i]]){ dp[j] = true; continue; } } } return dp[sum/2]; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-416-%E5%88%86%E5%89%B2%E7%AD%89%E5%92%8C%E5%AD%90%E9%9B%86/","title":"LeetCode 416 分割等和子集"},{"content":"LeetCode 42 接雨水 - 单调栈解法\r思路\r从“横向”看接雨水：每一层雨水的底都对应数组中的某个高度。\n因此可以把数组元素视为“底”，并寻找它左侧和右侧第一个更高的边界。 这个过程适合用单调栈处理，细节可参考：[01根基](LeetCode/08 单调栈/单调栈基础与模板.md#^zqyyg3)。 public int trap1(int[] height){ int ans = 0; int n = height.length; Deque\u0026lt;Integer\u0026gt; deque = new LinkedList\u0026lt;\u0026gt;(); for(int i = 0;i\u0026lt;n;i++){ int h = height[i]; while (!deque.isEmpty()\u0026amp;\u0026amp;h\u0026gt;=height[deque.peek()]) { // 这里为什么弹出栈,因为栈中的元素都是未被处理的,以后可能 // 作为左边界或者底部的高, // 但是这个元素不可能作为左边界了,因为右边已经有个比他大的了, // 也不能作为底部的高, 因为底部德高已经变为min(left,i) // 而 left又\u0026gt;i, 因此底部的高一定是 left, int bottomHeight = height[deque.pop()]; if (deque.isEmpty()) { break; } int left = deque.peek(); int gao = Math.min(height[i], height[left])-bottomHeight; ans+=gao*(i-left+1-2); } deque.push(i); } return ans; } ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-42-%E6%8E%A5%E9%9B%A8%E6%B0%B4-%E5%8D%95%E8%B0%83%E6%A0%88%E8%A7%A3%E6%B3%95/","title":"LeetCode 42 接雨水 - 单调栈解法"},{"content":"LeetCode 42 接雨水 - 双指针解法\r🔴 困难\nhttps://leetcode.cn/problems/trapping-rain-water/description/\n逆天思路: 每个位置能接住的雨水取决于左边也取决于右边， 是左边所有和右边所有最大值的最小值.\n可以先遍历一下当前位置左边的最大值， 再找到右边的最大值，取最小值即可。\nclass Solution { public: int trap(vector\u0026lt;int\u0026gt;\u0026amp; height) { vector\u0026lt;int\u0026gt; pre; int pre_height = 0; for(int i = 0;i\u0026lt;height.size();i++){ pre_height = max(pre_height,height[i]); pre.push_back(pre_height); } vector\u0026lt;int\u0026gt; back; int back_height = 0; for(int i = height.size()-1;i\u0026gt;=0;i--){ back_height = max(back_height,height[i]); back.insert(back.begin(),back_height); } int ans = 0; for(int i = 0;i\u0026lt;height.size();i++){ int count = min(pre[i],back[i]); if(count\u0026gt;height[i]){ ans+=count-height[i]; } } return ans; } }; 也可以使用双指针\n一开始两边的挡板都是0， 正如最外面的这个柱子不能盛水一样; 如果左边挡板小， 那么也不一定能盛水， 要算 左边挡板-这个位置主子的高度， 还要及时更新左边挡板的高度。\nclass Solution { public: int trap(vector\u0026lt;int\u0026gt;\u0026amp; height) { int left = 0; int right = height.size() - 1; int pre_max = 0; int last_max = 0; int ans = 0; while (left \u0026lt;= right) { //一开始就认准 最外面的两个柱子,初始挡板都是0, if (pre_max \u0026lt; last_max) { if (pre_max - height[left] \u0026gt; 0) { ans += (pre_max - height[left]); } pre_max = max(pre_max, height[left]); left++; } else { //如果右边的挡板小, 由右挡板计算盛水值, if (last_max - height[right] \u0026gt; 0) { ans += (last_max - height[right]); } //不论能否盛水, 都要更新挡板的大小. last_max = max(last_max, height[right]); right--; } } return ans; } }; 要点总结\r某一位置能盛的水既取决于左边最值， 也取决于右边最值。\n","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-42-%E6%8E%A5%E9%9B%A8%E6%B0%B4-%E5%8F%8C%E6%8C%87%E9%92%88%E8%A7%A3%E6%B3%95/","title":"LeetCode 42 接雨水 - 双指针解法"},{"content":" excalidraw-plugin: parsed tags: [excalidraw]\n==⚠ Switch to EXCALIDRAW VIEW in the MORE OPTIONS menu of this document. ⚠== You can decompress Drawing data with the command palette: \u0026lsquo;Decompress current Excalidraw file\u0026rsquo;. For more info check in plugin settings under \u0026lsquo;Saving\u0026rsquo;\nExcalidraw Data\rText Elements\r一开始0, 右侧的水是平面的高度为0 ^bHR4jyVR\n1加入后, 右侧的水是高度为1, 长度无穷的 ^yoNxE5sX\n0加入, 因为0起不到挡板的作用 ^shqx0Icb\n2加入, 挡住了右边, 因此左侧的水可以被计算, 而右侧的水高是2 ^UeV9fNj1\n观察下来: 我们可以维护一个栈, 栈顶元素是当前右侧的水上高(水面的高度), 如果新元素进来后发现比水高,这会导致中间的水可以被计算, 而为了计算这个 面积, 我们需要知道上一个水高的位置(也就是当期的水高是谁导致的)和底的高度, 而且有大小关系, 这就是单调栈 ^i7ZLsFTR\n1,加入, 入不到作用, ^zfBTHEcd\n假设来了个2 ^DzE6joPU\n什么时候计算接到的水: 发现当前值大于栈顶时 ^FhIScfDb\nEmbedded Files\r31b86ec3042a5782e7dce1ad42bce43c1aae5475: [[files/Pasted Image 20260116205555_160.png]]\n%%\nDrawing\rN4KAkARALgngDgUwgLgAQQQDwMYEMA2AlgCYBOuA7hADTgQBuCpAzoQPYB2KqATLZMzYBXUtiRoIACyhQ4zZAHoFAc0JRJQgEYA6bGwC2CgF7N6hbEcK4OCtptbErHALRY8RMpWdx8Q1TdIEfARcZgRmBShcZQUebQB2bQBWGjoghH0EDihmbgBtcDBQMBKIEm4IZQAZZR5MZwB5AGFSAGsOAClSfABZAH0EAEcABj6hVJLIWEQKwn1opH5SzG4k\rgBYADiXIGG5nHgA2JO2IChJ1VaS+QshJBEJlaW4eAGY1k+tlYO5hk+YoUhsVoIJpsfBsUgVAHWZhwXCBbITUqaXDYVrKQFCDjEUHgyESaEcWHwrJQJGQABmhHw+AAyrBvhJBB5yRB/oDgQB1c6SZ5/AFAhD0mCM9DM8onTGPDjhXJoACMJzYcOwal2CuGvxulUxcAAksQ5ag8gBdE4U8iZA3cDhCGknQjYrAVTTDA6szHYmXMI22+3asIIYjceU8\rJJJF7y4YATh4WsmDCYrE43DWbpOjBY7A4ADlOGJuAdhnG1jxS+9tYRmAARdJQIPcCkEMInTTCbEAUWCmWyRtNJyEcGIuHrwYV8QOGySBwnkYO8sV2qIHFaNrt+BO4LRDbQTfwLYDUSgQiNbNwjGD5upCGtEkjmg2BwQ2Bew1LuCS8Q2PAQ8WIYnlXBiFLTQxDWF5sEA3AEHWeIUj+dxxGNG4wEXSZUJuM1tWwQE4DXGlCgAXyWYpSnKCRNAACQAJ\rTWAArGAADVqNZaYkOgLAyROFY0AOA5oxOdVUDWNDSjOYgLjQF4420ETo2jb9oyjeVpzDE47geJ40DU7VPlFeNSnZQVcQhCoAGJ5QQSzLNZFE0QxdscTBUyCXIIk4QRLjtSpGlhVFNkwQlQ8OQQbkJN5bT+RCvz2PFS9tSlSRvSNUTIGVVE1RDTUTgcwcDT7LCEwtXArTHVA/Q3SsnR49BXRYyVHOS/DKoTQMyqjHg41eODrgTTNky4XiXgzJNszz\rDgCzQaN4mGeJozfacHRrOsd1QPcDwTNssWILsMlJAqByHEdVvlCcpz4jZhk/HgBKXR1VzQCrNzYbcyvWhATnrTAyQkQAAOUAAH1AGnNX5UEAZ+VAHL5QAQt0AFhtAHozQBnPUAI3TIcADazADK9QAuOWGVkKU4KBaUIIwkLjc08YAMRK6khOObUvqgABBIhlEG9BggpLy+qYKBzAIRmHhZ6BlVZPRslwR0mFvcr1yVUgHkdAgABVOIqAHgeoMGob\rhpHUcx7GPiEKA2Go8JCaQgEhA+u6ZUo+5Hh+1B5W0HSE3ICgle+lWgZBiGYYR5H0axiAiJIysyrKDhMGjABFAANDoeA4emAE1NBeemkmUWlNAAcTJvVWPgdjAmwKIOC+RZtRqydetKISoxphNxMk1Bo2G7UNLt7h4g+Mv9Ki4znPxdALKs0fbNRdFPScvEoTc4lPJx6k6QZWLAvi1qBS5Hk+WCwUYoqOKPWEaVZRDJUVUyjUDMgXL9UNfJCtKYrS\ruah1qpdYYFaP7amrQUjSjYtwF4NxiIBgQCdeUCkXgHB4PKScI0swpgVK+BBA1xqTVQDNSc/FpzXzKMtYIo5GzNktptRyu0ew5AfodYcRDxyTmnJOF4Gx5ptwTMuB6Ut/TsJesCN6JDg6FH/mUMOtIEAAGk9QHEkFUQYVQjDDAAEJ9A2HRA4Rhs6tHpjwAuMwJDF1LuXVkNV4jxFShAOuV0ThNwiqgSMeCO5aVQFsXSvckJ4KMsCEyQ8IAj2shXTa\rE9crYm8eZD8xANgUgpIvXyK8D5r1ZJ40K29Iq72BPvJkCSGp+CSqfBU58MqwCyng2++VqHeUtDeMqT0qrEGdBRYYABVb+Xo8moH/lMQuQCQF/HAWVWMPAepXQOKg7MIZTGjM4OgpCC5przWungqstZCGrXeq2ch3Z9rlITIOWhJ0zqMK/POF4DdSgcNfkuXhqyBElFASUYR5F0CtAaB2JIUAY4bGUFAHMxAYAcBjgrTQ9MqgcHoM0z6XSJDLgCcs\rVYawKwJiEq8FxjcUmoD4upW2TjzF6Xcf3Lxg9zL+Jsq2IJU9QkSDMuEyJ0SryxJFKvFk+LknhR3hvaKcTMlMoSsfXJPoz7anSqqIpV8cq6jKWgfsFSSpVIuQmR0dSaoQFdJyFpxBf7tJQtASFvAelgNWjNWM803x4P6mMhUSRzFmqmfmGZax5zXVjIsghCA6FrRIes7aFCtmSsfpAXZx12oHIunAyMpzIDnMetLS5r1iH7lIaUOAbBHRUMlShAo6\rEwDXxKMMFCfqwAZvQhilCYBnDyjzZhfk8IoCKIVY6ZQcrShZGILW7E9bG0CCiKQBmpBAQUDuEBDtGBsT017WwftIRqnRoTEm/AMBlBIPdfGzcoQoCgn0PoNQo4AAKybERRoIrckO8qw6tDgI07dyg6LiLopRfA4icw8ApEkOAdFWj4BeDmXRRdnyGO+NxQswwUW1xDEpaxaKriYs0vbNYt0Ey4p+Myilw9iUwsgHZSejlkPQDnh5UkMTl4Mvidy9\rlgowrNxrp2jlRGuVBRdryjV5ihWXwdtlbUpT76+vNJUyWNT5XvwaTHNVGqOnar0XYvVrU+khijBsWDrx4WTJZgcNhpRrW5ltSGI4/E4wvAnEtZZrrrnLu1FtTsmzezbNKAGt1p0GEXR4HJyBm57pDq3HwuNYRBH3NDhUCk9AY74D1AALX0FAOCfoOzRjWAregrQOziO/RUAxCGAO8SSHBkDCp5rgdZWgcCDisX2ySHghDaAPGbxBISylqHx72XJd\rV4e1KokEYyWKLJaSWUUeZW1gKJHSiJUYwU4VliSnis48aP1EBn6yoPS1MiAnarDCTsJtponAFSUk4ZaT45TqwMfNJJT4yEVqdGjaiaSETngVbmmZ1hm3VrNMxsvalmuPahs/s+zk4RLSVUxG1zc3nqxt3DcsAdyii+YkDANgOZMCvOYEJiF4mOIezS+il4f2LEhkU9qGxQCVPaBOfJKcJyVIwPDVIIrzwKdldQBVkK2G/Fj1JfVrDjXfHNdpd5Je\rvXD7MvI7YyjbJKu846/RnJQ3BUXxFaxsbwg74HWlS/QHtT6lLdwKt/lKupOrVgQuYssHIPanU4WTH6npkybWKYjYj42PypdQ9j1T2vUWdTZNmhgbxlfanFOESLmVxuaufwkzCY6YVHlIAAqVACmioAOBV1Y+zhgHRUqBAD+qWjQAB6aAHcvSGOM8YEyJs8PBuNsgU03XO1Yn1OJ82ZhUNmHNTvdp5vgavAtDZ4ROCLKI4tSC8enaUCEcsOCK2VhI\rSPsf4+a1hkn9Waes85/1obY2rAC9oHNgm/71sqcKidhT127t7YQDH3HjWvtp+p4z9noOR6hGQ7FJIQYmBhh6mwJoJLBJlZo/nJlnYzwpy5Yo3NAkJqMMKGHGDNJGM7KUI4vbJAZALTvTgPDPDVqPCSqZmSmzkgU1kkBEi1nSoRv5Hzp1gLmyoZCLpyu1v1pAINm0kxtLqNmKvLhKu7krrNlwvNpAAqmrsqsMK/tkq0lrmwb0hAvJIcKdEbpzIgsp\rqamdhphdiGBsLMm6CpkLksitMHhtMiM9pQorjskdLZsGpONGFcLGP7pwnxmckHp5uvijgfsMNHurIAAdqWMgA7rGACwcoAAxKgAhMaAD+5pDIADrygAFK657ZD57ExF7kyUzl5oAU50wt614IDsysiZjczuBxEEhCwd54xiwyg95TrcL96yz+DD4ewSB2FR6OEuEeE+H+FBEL5Gwmwr6oBr7+4IA2zQYhg76SiUD74VBlEVHDBuFeG+GBFX5g7Hp\rkRhxRycjYFxz0w5jRCuhGDbotqUTDDZxJy0hv6szizGKrDSSyTygvCtyPitzSQU5CQiR4J468QjLtxb4Ow9xGLlZIbs5M6oGBKs7bSM6c6tbkF9Z0akEhTEGpKkbpJ/GEHi4nwCG0GFL0HsbjY6FPw8Z5HsHhyKofzYCa5GjrY6o8BbYCA7YOyDKHCbATjSGSGgbf6JiSEW7aSnFwJpjAYcEO7GYaHoZaE+rMG6F7JBpfbzTdQnYb5mF94RqWEg4\rh6Jp7pu6FolAyk5rbA5p5oKlylgDFqZploVqTBTb/DVqtqOBlxDrNp6ntra6kHVqjp9oDooknDNoWnjpWlDqzrzqLqPbsKrrrqboyBBi7oprNTeYQ4np+YwBrCPovANANAgFJxRzZw9CkCchkxVD7jrwAI6oQApZuK7HRHRgHAHFHHML8QY6wFY5SQqb/62LiFQH3GwaPF9ydaM61Ys6YZfHs6Ejzz4Z4Gi6UHC5AlopC5JKdkAlUEMY0HDYsZRh\ry55QTZSpFTIlDqcFKqujJlDk/xrZaobYSaTDg4EmrQMKwYnJFhHYKgLhKa0nOKwRxjThMn4L3asnWFmY7Su6In+p6GfbnT8TFh6aCkQCRqCExoebilebX4+aBkSCUQUgICwIHD6DZycg5iKLbo5gUA+A9DVjiLyirhI4/olypaVyFhW65nHEFlnGCT45C7XHCQoJ3HtHRGlYZnPF1mvENloGfEhKvE/Edngli6Alka9k9acVdnUHQmjky7jkMGTl\rPnTazmmkcGLbcFoZpmNSrnoRiaXb4lsiElxjFhFjTQkzG4yHPCdQnmaa7ZW4HCMlXmqErLqF3kcmvZcnWYvm8lvn8mfmmGB7A5LqAVjE34gXoCYB6gUCpzxAKwKzZzyiNLKAIAbBVAUD4BkyNJwhbHfk7Gf7rAkX5ZRhlmFhXnQEkFwF0V04vGYG+JMUfFNmsXFVUrYE0q/E0YUGDndk8V5a8B8V1X/FLkKUS4jlS6wnFJiUK5WaUhSW/n8bokNJ\rc7i78HYlrm4lqVtQyaDLQJGGOaHmoCLR6U0nGXOKmIRjZlpgGZqFWGermYvbSlTYfZOWMLzQbB6a3HsIA4jUWEeWumSkpp9jpolrZpZpKkfWZrwIlr7CaklDaldo1p1oGnSXDotrg0NqQ06ndp2kTqDqQ22ljpI3WnahOkLoswvURrukGCek7pSl+lAUBkTEVCNIICMTRgUg5h0TyhJVh6f7ObagXEwJZXREzgHHE43TKSqRQadzaQ04FUIEEqVW\rlXIjoHNmVXsXc70oEFcVUZNXdadYDkdWCUpTCVwkJgcYSUza975EyVjVLYNp8HqptLmHbllRFibD2p8Rfkm4agO0yGnlHGbDygiR3VkQsnWXHUPmnUSUXVe7nTxDSRThmJuWQ3ua3mV4lHoA8D2GoCeGADy8oAGFyoMgAnfGOGAAm1oAGe6msgA98qACncoANURgAhdGADp3urIADABCeKMsMOipMIRpsheTdUApeVMFetMVeTMAsdeSRXMTeaR6\rAbewsWR3eBtqJA+RR+APREgCd5RSdadmdOd+dMMxd5dVdqAtdms9djdukBs9Ry+ZspAFsLRbRgtDsnRCU3RI+8didKd6dWdqADhedhdpdldNdddDdox4ODyYcFIRgPQhAQgjERgsGnI1EqcBweo1EmAbAnIiiLwSV0KmZa1kCTsRw8m4YX4b46V6KZYHN6KXttw9xOKItRVLkKGKB8lGGwS08VDHO1VuBct+BjKDVSSwJLVqt/FDVGtAqCYzGIld\rupQutg1klMqk9b8xt3BvIZtImM14meJm5QhZUc0MCp0sYV5jtvAmwRlchU0odChM0X5llRmvtzuJ12h4jQd9CIdhxM0hlVswpht35Ypnl1hSab1+Qv16EX1ua6E+aKpap6EGpgTlah4upMNhp2IxpENj1na5paNDpKNI6yTk6jpYIzpONTubp/wHpW63pxNc2/p/9yWPQsW+A8ZhAeoBsPA1YmgTQTQHY2AjStIjESV6ZRiaOSQN1mD6wsYODl0X\r51MEyuOEGQuuV+WVJ8BlDPibxtDUtFVjDrZeGiIHFbVEJ3FW8zVfZZBmzitnVUJmtPVI2fV8JjBU5U2+tGNo1XBrohAWJ3AOJ4mwCKj+q7U8KRhcChDG1A0Wm5JaCW1Kk00b4X4l0B1VlR1lj/t1jb23Jnudj04ZiemxYQuP5ltbjz1oOf9t+EA9MlE2c26ZMHQrQNakg8oTQ2AUc+gii+ArQzg9AuQmFyWv6OFCYVcbw/T2Dn4wz+Dn4JDpwaKo\rdAtTiJWNZeKDF4tNDdW5VDD8zUS2A0YSrtVCtXZnDvFPDBzAlw5QlpzY5IjN8CJ4jNzc5slrodETzf8ijql7zOu7UoB8Q+2Zlq1Ik2jLtwLQG00pi00grZjjuEp7JLuAdNjjlwdyLkYZiLrzj7l/5HjpTeLHQbA8QMcFADQdEmgPAnIggjEUcCOhACRdE1YKDKVuFvEaY2gU4KkEYj48Kc0QuQkMCX55F/1CYUz3D8GFDUrjDCzsr9D3xzDE1T8P\rOvDHVGruzrVarfDurJzgjdB5zOtxr8LSJkjtzC2MjroGFPKK5AhLzxMc1hJJWGW4ENugL5qrGFO5uW1XUChHtJWpjPt0LZCwbcL9lz5PJ4bM4DjaLkdCTWLcbuNEAXjdlcpKp/jP16EwTTJJQgN4TWpVa3acTsNf7Rp0TcNoNiNKTKHaTlpGTkNWNLpuTZy+NG6hTxAPp+6bBCbvlEAjEz+pAPAeorQdEeoHQUA9AdEawjS2AmArQRgqonTbLGZa\rOM03crNqweD4zzV0bbbZDrwEriG3b8zEt6GSz8rs8MIaz9elII72rHDlWXDez1GU76tM7Aj/e87oqFz4lJrw1mL85H8+AVrmqyl65bzpNVtqwhwRxZlNuq1OWfzY0wLChV0jm34D7N5Fjz7VjnJ05DlH7SLX7lqYYVJGLIp/7t51H5NEg9M26mgRgScOYLCmgDQ+g9AmwhArQrQUcbAiixbLL+iQn3TZbmCrbWWa1IBRDR7or9soYX5szSnRKMrj\rZ/bbFg7qr7DY7BnmroJQoo738xz5naUlnsu/VTBcXQ1q7ZrG7ww+gzne73Sdr22q00C6wZlvn/nQuV7BjDsfXsC8KFlj7AFNlL7sX51YbiXTr00S1v7mL0d1lWXHBYchA8QwWVQzAZMCs9UtMqZTNLX4EOZZl+5H5nUsYITP+CoYYawhO/JTmjmTrxYRDp0moyQl0s0ChRZ7b9cCn9Fs39Zw3zFcrjOiryr0YE3xG+nPZE7WrJnC3fKs7FnvVVni\r7lzetdn6XDnDSXA8jFt6X81mPILVwmlq1/E7rm1N3JOM40kVilYT3Hjft3qIH73CXDswa34jjFZQpsbMd3dcdEAgAQ5GAD72oANBygApuZoCACIRoADdyxdgAL36AAlRr9IAFRygAEBbqwh+ABuGYAMKKgABL6wyADK+oALJKCegAUHIowAAU0M/saMAAlOrIAEGagAOeaAANprH4ANvxrvMegAi8qAANzoACvW0MKM1AgAm/GABY8oAD/agAL4G\rAC0coAC+p69H9W9AAOhwNXRjKnZXc30HyP4jIAPee6s3vgAAOmACBkYAKfugAygkp/B+N+QyACy8oAHb+6fgA+nKACMOgn4APjmMM9dgAgDFd+Qw5+AAxKoAKl6OsNdgAKHKACQ5oAOSagA8DqADOioAG+fdWM3zP6ABVZUADAMSH2CL4wW62kK8sXnbqRFqYsdBmL3XiKJEEEKRXmGgPSLt5sI49HIlI0FSFF5Ys9O+g7xd7u9UA3vP3oH1D7h9\ro+cfJPqnwz5Z8dYefVAEX1L4x8K+1fevo3xb4d8e+/faGBvU/qoAR+Y/CfhXSn4z95+1Ar3ivw35b8g+O/A/sfzP7x9L+jfWGLf07739n+r/bep/1/6ADgBYAyAayFwCH0l8sApoqfWsLQoL62Ka+i7Fvp28nebvT3j7yLoB9g+YfVAJH1j4J9k+msNPpn2z4cCuB5fSvrXwb5N82+XfPvgP03rqxJB4/SftPw4Bz8F+igtfpv234ow9+h/U/hfy\rv66C7+j/F/ujHf7f9/+QA1ACANhgQCoBgPERLXg6DRU2AtISQPoCEDrESopXQgH0A7D6ABgJbGUGg3DDY9Zk84N0PCgXB6Z8G4dWSNXF2prDm2aKdHpTmooPFXETxQqoN2QL+I+2DWGWuNw2a89+cM3bZnNz06mcuqerOdkL1W7WcBqy7Tbsrj/aS8lsbAfbja2eAHtVo9qFSKe32qBdF0bwM9udgwRxgVMfEN4Idl16Rcn2mhV7kbw9z6F7MPrO\rMH5xjZR13GgHYDtKV8aTBwOgTZUiWnR4wdy0cHYGghzBptp4mmLVDkyOQ6Yt4aPaXDsjWw7EBMOeHP9gRxyaBtvyJHQmkU19IlNSaZTCQA0A2A9B9A8oUgB2CTQ5hgsuAeIEsSTihY+gFIDoIJ2wrCcWu84DYNoFmFRgzKIkBxssKjCrDvw6w+0ZsOaqW8dhl9asvsNrJ09GKDPMqqN2KqrMSQ6zVhmrUSTTdues3EMdkkW75J9WwjCcu8LfYSMv\rh9nc1sMDwGTVzau7AEZtiO6ed0sDjOaCYQhEswwwV3D1jdzgSDM3Q8oOTJC3Maoig2MXDEe9g+6m9sR9bIDFeTS6uN/uVhNoY8ggCYBt01YZjg02ojYA+gzgfQMFhjiMR8AjECgBQA4B/CGu6ALpv+mNH8QzRkCOYZaMWFidEU3AS6NGDtFhgfOjoohiKyoqX1xWHoyVl6OlYnCRuZwntizxVaXDJuoYrnirQjHzcox/PJbhACEba1RGS7RMaa0h\ro/DuCgwf4a5x1TudvK9rEMHmTnCtxBWOjS1NCNkIYI3gZ3K6C6P9Y29ousLN7piNfLIsOxeI+6gHgJHYt40/YsOGXEaSaBSAMAMmM0Doj6BKIMcTQIoiqCURNAtIVoNugmHyUq4cEIAsi0gQlZwWVJISCsPtT2jzxWPIhm11Ia7ChcA3B8T2xU7Ko1OA7HAkOx07y1Px1w8MbcMjHbtoxew54Wc2F6gTRetnLbpBNTGQgZeWYuCUoyBFBpZhJjNX\rv82ywHiG8QLCscwiOLyQ3wj3FEc9wN6PlQ2JvOzCHUoldiHqf3QkUR0gDEj3qkHT6gqW+oUjSRJQNSTB2UZakImG8KJmyJibQ0qp6HJJtyLXaQBUaDUzJnOmxoNjRR+TAmmRwo72wKoDE3ooQApD0tAG8QTQI0g2D0xowHAA4EIBeCUQJI+o1cWmSa4biOWXcESJW3WALhLoB2GMEFIx6YI9Mp4h0SpKk7NwZOlZXYaGExxaTbh9PJ8Yzz9ErNcM\rgY7TtNl05XCiCNwpWmCXuF89Jctkg1vGPW7XNxerjKCa6GZbbspqzzbMRuQ87qV9kV0YnpdMgA6MaxZY9XrCNmhRgJwcCKKYdRikwtDeZ1MiZdRnDJTfu6XXsc9wGkSAbw2AbdH0EYg5hKIjEYgK0HESUQjAUAeUPoABRrAOq65FaYaOa7rS0Al0OIL7h2lAYVM+0/BqHWx6KSzx2ZC8edPLI69ZO1035p2wOGi0qsj45nE9JfHzNZaRUT6aZO+n\rmTfpdwr6ZCQAkxigZcYtblc24zOTvhqY96VPAUaeTbWiM+Xqxjrbes1J1JAKQ7FrHFjTyNYtHoMiuCEyoWxM4iaTMDqtjEpFEp1FRLOSpSaZ6U+idKLxZGAKQiiBWJRBabCzYeH+Frp+GGDaAzKjmT8B7QbkR1xOvEFhNoDVmqyNh5iciqdEdhGF5ISkNYOGHJ6hgeu1OGnocO0nKcfRktFiup0pRvi2eH4jnlN2/GC5J2Vsh2YDMF52TXhIvGzh\r8KTGsEUxO3cYO5N9By9CS1bHaU238nnslhUc4FgpELEbBTEfrPXoB3vIpz4piLNsSHW/CzIUpNEv9rTP1628D8ioROlHg8KBFqA0A0Iq3W8gREy8yAiBcPW/IJF3pyRIejgJHoZF8BosCeo1KAkkCh8ZAu3lAsXowL3CcCqwTYIaIn0z6VsVomQ1cEDZ3BkC6gNAtgUBF4F9M9ALekwC4AmgfQOKi8GcDRgmgCsNgMMEwB0R4g2ASQNnANF/oxJR\r4t0JWy0aNzSw4dcxPJJAJOxMZp07aUQ1bgJAKc7bT8MkEnkGyHpxs30abI07uQ3p7PWjGvOVobyeeW8gbGZydm7zgZrssXh7NPn3Nhg9AWCaHngneTVgPzN+esGdoUk0AcEfRhghrFvzNglMj+dFPAXJy4pR82xv/ORa3s3QFObsaiTAXvQBFEAasJgCMBwBMAwWQmPgBzCRgNgCAJOJgGIDVhiAScR5stNQY9MiwyQUMLMm+4CsrydcOBIkBVkm\rKnRF0wVlT1sVzMhuj0xxRgR7bmzh2Jk1eV+M8V5VGqf0+2b4seEC9luLw0Sm8NBnuzkxEvVMSsAvlwy/ZgI3MUjPaiahROmoXWcFPPZpgsZIUtJQwkgSdQ0Z15ImbkrRFNiyZLYhKcGi/ARhtM1MnsXnLZJAcpS2UyYGBzykBN4OhU1UiQxpFA0wAINSqfqXZHpdWRZKodJyP5E8iWROHe0gKMxZCiOp+AMUT1OKZUcC5NHJOEkGzgwANg4iPoAr\rBjgUhKI+gZwEIGGAvB9Ak04YIlmWnrj1FvEMsCMr1zyRQ6Ey/BguBkizLlJaVDWV53HnTNllRw6hmsrnlM8Wyr0heCvPcV7KdmP4iyX+KsmOybJASl2VcrdksEiBdzBcsMF2CPLrWzynMQHOvnfM+I9bVapGESUAqZk0COzApENZgrE5EKxsSRObEIssRAChFetWokuMKlKKhANUvlDEAcwHQasEYAViNJ9AnEzQFKp4BJxKAFIaMFQEVWrTlV6K\rCSclzGWaqzo2q2ZCdP1XzLbEV47WTeNor6yVlxwhxZauekKsKQSrd8cGJdWzdDOm83Zf+J3nnK95lyg+QmI27HzfV67MJUYEiUplXmMS/LEcAvICtVq10VJTMleBFh354I+3Dkq/m2VoVWa8iTOE155rs5ICtKXRK8q4saOBwKoPEHoD0AqgMcegLSBjiYBOQygbOBwFGB9BKIPQBmgMtLYSz0U34bQDGAUIU8pZLoqZVzT1WdyDVqKaTjlXuKaS\ru2081ZbOtU7zyDJNVO1fVQ8WOqvFv4/6Vuu6rOyQJRrRyUfIgmeyN2UYc9Z0i8mvLA5EYb9sMlWopdH13ANCUcFMTQI6xAbVFd/PyWJjCl6cv9bmsFblKgcAHDKWiu8Zpocpmackbits1FpoOpaUqcDXKlmlEOaHXkUh2pUYd0mdKilQyvRqtTsmrK9lV6XI6cr+p3K7LugBjjJtvAvQyQLSCqALgk4N4TQArCTTYBMS7asWWtNhTJLNgTsO2oMh\rEifgnWkyrKPhUo0bDVJX5KnvJzvGKcmNM694nOqcWuRNOrizje1QdVdZeNzq/ja6u3VASVue6hyYfPAngzUSkMhcNJpUqHcw1uuQ4scg9rJqdGZYWNUFwrHW4fmZYBOfWKTmQqM136+Ln/KM3wrTupmnOcipA3FqYtQPCoHKMaSSAkgUcNYCIAViSAjAHYarggGCyMRlAMAc+TD2RxKq0GChbHmWD4hlaVIpiFaq3KDk5kqNw6sxYVh1n9dGN907\r0RatY1WrzhhktxVxv63rrvFm6kbYJo9XCadQom6bSEruWSb96GY32VEsvVybr5kCGsV+EgrRqUlT80KaYmJ4HbtNREk7T/IKVpy4Vp7cMKl1u2Fr7t1S7dOIiEC0hLItIOiDwGCyDBJAwwQYB0AVjDA6I9ACgA8rB1YU1FkOw4k7E15mUnW2vBtjJgXBGKlJVG0sGYpeAWLjVa1RIMLSnVmqSqs8/HfOucVtkgxFsnZfarMlOrbZlk7eVTp3WBKv\rVwS25RDPNaHEFtbnK9XYm+6KRI5EhcOdaIF1pKedMCTqBC2RHgrP16Is7e+wu3S6TNSKhXRZvzmISyaT2iQJWo7AHA6IbAbdOCnN1Qgq5eG3BHXN0U6Lm5+itTU6yHVu6e5aKUAt7qLJ3TbZ9i9rcHs60j0bV7ZFdcNrXU/TDldsnxcuWskwld1yasRmJpm3SN7mHtZzhyOvkZZemPOjbfpW0jbaYR8a41I+DMQqFP5lmvTSG0l2wrsRB2e1JjjM\r1/kxdUwcgYAHEFQAH3RrvVOkHx4DoA26iC7SOERLxICu6oeHuvzHQHYLB6qRPBYLHTGlBO82RCWCQunqkC566ABA0gZQNoGD6i+RhdwGaIsLnBvXdhVQU4UVBGDyB1A7/XGId70AkgfADHCSAMRel26bdMnGIBOs6IjEcRDWr255bLdaOVhGaPH1mJdDU+qaARtq2Oj59zVBQrwddHYo+m8QWDIPLsODzTVrW81Sxr0lsbrV3W21bvuOW2yydfG7\rw0czdVn6k9+665T6pIVzb4IMMzMdNRDUIy29eY/DV6xUxjzixG01TQqFLAdjf9ouqLuLv02HrDN0usxCwnMSQGeEiux7e0NHxsAeggwCkLSEwCMQOgScIcBIlQpRwlila1Rey0K0txLoY+yffofwbyRPdxRruSYdUmmIbF147FKHQSC2H7DdhxwzjqNnr7XDBOzZRcK8PH7D9vhobf4f4b+LE9nqkI96pnIM609km90EGpc5s7/Z8Rt5SGHDBvB5\rMWc9GW/vRQf7sJMyScCpFmhKQ/9H6gA1+tTkgGAFJxJ1k3vM2ZdKjA4smJID1C0hsAFIBpozWH29GfcdcosFdFJz21Bk2q2DLXNAK4i5oQGTJV1y/DJAlIcyJNYpLo27CZoyx1fbjpcN0NN9OGDwzvoj1sMKd++m2Yfrj0nLT9WtBdpNoPVgyLjs29PfVyiMaoH9J0eaOORmjSrlNB0sOTtthG4S35JyCnIRNyPpqJdf7Qo/ZhuqOYrcZS+XdCf1\rM2EKggAAblAAEnKAA300ACYCpXUAClxu4RhhoBa+SfQADwKX/QAHFykfR0yPw9D8GJADpl0+6c9PQxvTNfP04GeDOhn0DdgxHUVBQWd1oiKAjBf3UwG4KCDuAsekQsIE0GyFxRA/JGddMV0PTXp1AD6cT7+mgzEfEM9L1YNH07BnB6iawuuk75qlqGsmIQEYi0hqIlESiHAGrD3oS5DQYgDSG5jdGjRI+gnKAVfDKnHMHtQVnXAGayQ0JN0M7qdB\rxw0bm49qOICckOAsJOohxQ4lSXbZlg4gNY8028CUgY55IgrFfYfrX2LM3D/o7feHu2U8mo91smPQKdXVCnAjIp+ySJqm2HrxNoShcqdEz3RKOdJ0N0NJCMI3VL2Hxv3MXqfWONOxGWHIxwYcGxSgDRpqXSadgRC6vyZRp6i3tA2iGqj6AKOD0A6DYAjd2cJzstLh54abcysicIYTfmhg/1BJ9YB3Pt3rBOoVuWDFeXIr0nL6y+7HcydWOfmNjPiA\rMZ4e5OCmfDB+/siBZP1gXYxNOy/fTtT1SnJNGwe/VfINR4Wbcx5VI1JHSNEk72onK3ARdXxEWSZ+mzFsafOjfh9pHtKE1AetNh4JAYZt2OQIQWpmsDiA1BbgYAT4Ga8UKLBQPUbwkGCz+C8g5AEoPEKh0tB8hfQYgD0K2Dx9Qi8wq7PcGOiYYapfQBgDDgqgOYaiOocH3v5UcLXeZDjwRV8QrojJTHHXE2BJAO5N0OMGMsug1iiGslpxPJf91OHA\r9eO9YyHq60uL1Lf5zS7se0v7MDjfi91cccMtgToL1+1XHBeXmynZerjQOV1GknQJYE0ahy6Muuzfgborl+waVbyMkWvLZFny5BRORqnqLope7SgIqChX8rEVxorpXTPYGYrWZ9BaQdzMbUsBzeUg6PUyLFnqDOVssxQoPyFX2zjRTs4Bu7OX1HYlV2E2HGCxJADdk0ngGes4vonIANUKMOYrMoe072smTUBuZDBhgcyPne1JkZ1V/5DVkspfX7s9\rErGdJQeua+ybUtcnlrul1a/yZ0t77QLo24CaKcgvimblJ8xnbfvpgWXTrhJUygzZBHXXsL0+q4CpCLJ6mSrL3KFZfNcbeXLyWPSBHgh+sZcgr4VromFbt7A2wibdDulETWrZnobSVvM6lYSvpWizXeEs6jcHzlmAbdRWwdjfctlW2FhNtvTKMYvZwmgFIKOCpDbVNWR61NwcTJiuCE5wDVuTUK3DLvaqwwpo14DGBuwLgsEFOGSwLaZPvmWTaxtk\rxstUs/n3pPkf8yTuj2DbY90tw41tbG0XKL9u1iUyZZv1wXFE2t1EoHLTChpMlVJHRmbnLFpKrsC4FTFhffXgqcbBpzy+l1tsbCGbAV8o7ResLBX0AgN128gubog2orPttBXge+g5nA7sN/MyHbINh2qDuRSOzPSBux32Dbl56xvjxsuDk7YG2LWmXUAxw6IlADXFTZat4bZwW08CGGBAIpdIwldzaSVgdRzRJLiksa83ea209hbM82ax3elovTOT\rv54yX3b60D2Dlctja6csAlK2ILtOqC1PfVuXHb9TQee6o3kKTgMlVwde0krsQ3WJlGWUOlSXNugPLbp2626iRPvfgjgb4Uo5acCsdTr7BVt20A/vswDH73tnA5DdfuoC0rmCjAZ/eDut4CFCYLKxHchq5Xo7IV4B8VcUfn0k7SQIiOAEKgQBcAcAOAPSGOhPKpgdwTILXnaJLAGABbCgIon0mvEokKTodhAGwAiBPIeoesPoHpAM427MKdJ5k9JD\rZOMgiTr87Q8WuS2invaEpzk4HOR7+7hQGp1k5yd5P9lIJTK8U+yClPcn61nxS07qcZBqIm10SIM56c5OGg42kRuM6gC9OyYGZ326clmfzO88qZ4DCs5yf7537Njig907mdtO/NLU00ps4yAdggtWHQJ2jSRCnP9AiNBWKmSng3PmAOEMEJIb2BzhCcN0L61cC16QnmnLzwEDSBWxoBnAKmfq28FEJlgXjGOZZ0YDYAGBwn6MggBbB+DbmSs8kf0r\rc5Gc7tTwTz2JxiBIAYHeABkHUES/rB4RzHRrEgD0DYB1JznuATQMEFyNkuF5txyAIojBBhxSAygFEOnwovqwBXvAU6OrFrlJAc+rIY2MoDtDwhksvL3APy5QS8AlXNd0V8kAldYuMntTkIpVimfcxOAKj2JzNmNhOhZY8TYRFkEZfMuLbHeIgJS6evWEh8UTrx4KgNioNFHWLuwHRF/TMBaQQ+OALS/pdD4mXYuwJyXEICMAZFYIJF4tqZDpBw3K\rYDvCeENj6AHn4mYDZfZXT/B6Y4byNwi6c5Siwc4AO5B9OCDYlCIIAQiEAA== %%\n","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-42-%E6%8E%A5%E9%9B%A8%E6%B0%B4%E5%9B%BE%E8%A7%A3%E8%8D%89%E5%9B%BE.excalidraw/","title":"LeetCode 42 接雨水图解草图.excalidraw"},{"content":"LeetCode 424 替换后的最长重复字符\r🟡 中等 https://leetcode.cn/problems/longest-repeating-character-replacement/description/\n特殊的点\rmax_count 的更新不用每次左移时更新。\nwhile 中的更新 max_count 操作可以省略掉，因为当前找到一个 max_count 后答案就已经更新为 max_count+k ，如果有更大的那么一定时 max_count(新)\u0026gt;max_count(旧) 因此 max_count 应该是递增的，至少不应该减小。 因此 while 中的 for循环可以省略。\nclass Solution { public: int characterReplacement(string s, int k) { int left = 0; int max_count = 1; unordered_map\u0026lt;char, int\u0026gt; hash_map; int res = 0; for (int i = 0; i \u0026lt; s.size(); i++) { hash_map[s[i]]++; max_count = max(max_count,hash_map[s[i]]); while (i - left + 1 - max_count \u0026gt; k) { hash_map[s[left]]--; //for (const auto\u0026amp; pair : hash_map) { // // max_count = max(max_count, pair.second); // // } left++; } res = max(res, i - left + 1); } return res; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-424-%E6%9B%BF%E6%8D%A2%E5%90%8E%E7%9A%84%E6%9C%80%E9%95%BF%E9%87%8D%E5%A4%8D%E5%AD%97%E7%AC%A6/","title":"LeetCode 424 替换后的最长重复字符"},{"content":"LeetCode 443 压缩字符串\r🟠 中等偏上 https://leetcode.cn/problems/string-compression/description/\n一个指针 slow 为写入位置，一个指针为遍历位置。\nwhile是递归形式。递归函数语义为：写一个 a 2。\nclass Solution { public: int compress(vector\u0026lt;char\u0026gt;\u0026amp; chars) { int n = chars.size(); if(n==1) return 1; int fast = 0; //遍历 int slow = 0; //向slow下一个写入数字 while(true){ if(fast\u0026gt;=n){ break; } chars[slow] = chars[fast]; int count = 1; while(fast+1\u0026lt;n\u0026amp;\u0026amp;chars[fast] == chars[fast+1]){ fast++; count++; } // cout\u0026lt;\u0026lt;slow\u0026lt;\u0026lt;fast\u0026lt;\u0026lt;count\u0026lt;\u0026lt;endl; if(count==1){ fast++; slow++; }else{ slow++; if(count\u0026gt;=10){ string s = to_string(count); for(auto\u0026amp; val:s){ chars[slow++] = val; } }else{ chars[slow] = count+\u0026#39;0\u0026#39;; slow++; } fast++; } } return slow-1-0+1; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-443-%E5%8E%8B%E7%BC%A9%E5%AD%97%E7%AC%A6%E4%B8%B2/","title":"LeetCode 443 压缩字符串"},{"content":"LeetCode 45 跳跃游戏二\r中等偏下\nhttps://leetcode.cn/problems/jump-game-ii/description/?envType=study-plan-v2\u0026envId=top-interview-150\n这是一个关于分发糖果的算法问题，属于贪心算法范畴。下面给出 Java 版本的解法。\n问题分析\r每个孩子至少分到 1 个糖果。 相邻两个孩子中，评分更高的孩子必须获得更多糖果。 在满足上述条件的前提下，糖果总数要最少。 解决思路\r从左到右遍历，保证右边评分更高的孩子比左边多一个糖果。 从右到左遍历，保证左边评分更高的孩子比右边多一个糖果。 对每个位置取两次遍历结果的较大值，再求和得到答案。 Java 代码实现\rclass Solution { public int candy(int[] ratings) { int n = ratings.length; if (n == 0) return 0; int[] left = new int[n]; int[] right = new int[n]; // 从左到右遍历，确保右边高分孩子比左边多 left[0] = 1; for (int i = 1; i \u0026lt; n; i++) { if (ratings[i] \u0026gt; ratings[i - 1]) { left[i] = left[i - 1] + 1; } else { left[i] = 1; } } // 从右到左遍历，确保左边高分孩子比右边多 right[n - 1] = 1; for (int i = n - 2; i \u0026gt;= 0; i--) { if (ratings[i] \u0026gt; ratings[i + 1]) { right[i] = right[i + 1] + 1; } else { right[i] = 1; } } // 取两次遍历的最大值求和 int sum = 0; for (int i = 0; i \u0026lt; n; i++) { sum += Math.max(left[i], right[i]); } return sum; } } 代码解释\r创建两个数组 left 和 right，分别记录从左到右、从右到左的最小糖果分配结果。 第一次遍历处理“右边评分更高”的约束。 第二次遍历处理“左边评分更高”的约束。 每个位置取两者较大值，累加后得到最少糖果数。 复杂度分析\r时间复杂度：O(n)，共三次线性遍历。 空间复杂度：O(n)，使用了两个辅助数组。 ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-45-%E8%B7%B3%E8%B7%83%E6%B8%B8%E6%88%8F%E4%BA%8C/","title":"LeetCode 45 跳跃游戏二"},{"content":"LeetCode 455 分发饼干 - 贪心解法\rhttps://leetcode.cn/problems/assign-cookies/description/\n思路\r核心贪心：每次优先满足当前胃口最小的孩子。\n双指针法 孩子没被满足时，饼干后移；中间无法满足当前孩子的饼干直接跳过。 while(){ if(满足){ 孩子后移 } 饼干后移 } 代码\rclass Solution { public: int findContentChildren(vector\u0026lt;int\u0026gt;\u0026amp; g, vector\u0026lt;int\u0026gt;\u0026amp; s) { sort(g.begin(), g.end()); sort(s.begin(), s.end()); int i = 0; // 孩子指针 int j = 0; // 饼干指针 while (i \u0026lt; g.size() \u0026amp;\u0026amp; j \u0026lt; s.size()) { if(s[j]\u0026gt;=g[i]){ i++; } j++; } return i; // 满足的孩子数量 } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-455-%E5%88%86%E5%8F%91%E9%A5%BC%E5%B9%B2-%E8%B4%AA%E5%BF%83%E8%A7%A3%E6%B3%95/","title":"LeetCode 455 分发饼干 - 贪心解法"},{"content":"LeetCode 457 环形数组是否存在循环\r🟠 中等偏上\nhttps://leetcode.cn/problems/circular-array-loop/description/\n依次检查\r这里检查不用双指针，一个指针即可。\nclass Solution { public: bool circularArrayLoop(vector\u0026lt;int\u0026gt;\u0026amp; nums) { int n = nums.size(); for(int i = 0;i\u0026lt;n;i++){ int count = 0; int cur = i; while(true){ // 正确返回的情况 if(cur==i\u0026amp;\u0026amp;count\u0026gt;1) return true; int next = (cur+nums[cur]%n+n)%n; //只有一步环 if(next==cur) break; //中途反向 if(nums[next]*nums[cur]\u0026lt;=0) break; // -------O 走到后面有环了。 if(count\u0026gt;n) return true; count++; cur = next; } } return false; } }; vis数组\r每次到一个新的位置，设置 vis[cur] 为起点位置；每次到一个位置，查看vis[cur] 是否有值，这个值可能是本轮之前到过然后设置的（这说明又环），也可能是上一轮或之前的轮遍历时设置的，既然之前设置的并且已经开启了新的一轮，这说明这个位置不在环中，直接开启下一轮。\nclass Solution { public: bool circularArrayLoop(vector\u0026lt;int\u0026gt;\u0026amp; nums) { int n = nums.size(); vector\u0026lt;int\u0026gt; vis(n,-1); for(int i = 0;i\u0026lt;n;i++){ int cur = i; if(vis[cur]!=-1) continue; while(true){ int next = (cur+nums[cur]%n+n)%n; if(cur==next) break; if(nums[cur]*(nums[next])%n\u0026lt;0) break; if(vis[cur]!=-1){ if(vis[cur]==i){ return true; }else{ break; } } vis[cur] = i; cur = next; } } return false; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-457-%E7%8E%AF%E5%BD%A2%E6%95%B0%E7%BB%84%E6%98%AF%E5%90%A6%E5%AD%98%E5%9C%A8%E5%BE%AA%E7%8E%AF/","title":"LeetCode 457 环形数组是否存在循环"},{"content":"LeetCode 46 全排列 - 回溯解法\rhttps://leetcode.cn/problems/permutations/description/\n思路\r和组合问题类似，但是这里是排列，组合是每回只能加后面的元素，而排列问题可以加之前的元素，组合问题设置一个 index 来标记从 index 到最后中选值，排列问题每次都是从全部元素中选值（去除已经选过的元素）。\n需要标记那些已经用过了， 可以传参也可以设置全局变量，但要记得维护。 这里使用全局变量 use ，没用过的为0， 用过的为1 for(int i = 0; i\u0026lt;max;i++){ if(loc[i]没用过){ path.push(loc[i]); use[i] = 1; //及时设置use add_path() use[i] = 0 //别忘了下一侧循环时，本层的i 应该是没用过的。 path.pop() } } 代码\rclass Solution { public: vector\u0026lt;int\u0026gt; path; vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt; result; vector\u0026lt;int\u0026gt; lco_number; vector\u0026lt;int\u0026gt; loc_use; void add_path(){ if(path.size()==lco_number.size()){ result.push_back(path); return; } for(int i = 0;i\u0026lt;lco_number.size();i++){ if(loc_use[i]!=1){ path.push_back(lco_number[i]); loc_use[i] = 1; add_path(); path.pop_back(); loc_use[i] = 0; } } } vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt; permute(vector\u0026lt;int\u0026gt;\u0026amp; nums) { lco_number = nums; vector\u0026lt;int\u0026gt; tmp(nums.size(),0); loc_use = tmp; add_path(); return result; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-46-%E5%85%A8%E6%8E%92%E5%88%97-%E5%9B%9E%E6%BA%AF%E8%A7%A3%E6%B3%95/","title":"LeetCode 46 全排列 - 回溯解法"},{"content":"LeetCode 47 全排列 II - 回溯解法\rhttps://leetcode.cn/problems/permutations-ii/description/\n思路\r和[全排列](LeetCode 46 全排列 - 回溯解法.md) 类似， 只不过这里需要去重\n这里的重复是 for 循环的重复，可以参考[组合总和2](LeetCode 40 组合总和 II - 回溯解法.md) 去重，设置 last 变量记录上一次的值（这就要求排序使相邻元素在一起）。\nfor(int i = 0;i \u0026lt;max;i++){ if(loc[i]没用过){ path.push(loc[i]) use[i] =1; add_path() path.pop() use[i] =0; } } 错误的\rclass Solution { public: vector\u0026lt;int\u0026gt; path; vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt; result; vector\u0026lt;int\u0026gt; loc_use; vector\u0026lt;int\u0026gt; loc_number; void add_path() { if (path.size() == loc_number.size()) { result.push_back(path); return; } int last = 11; for (int i = 0; i \u0026lt; loc_number.size(); i++) { if (loc_number[i] == last) { continue; } last = loc_number[i]; if (loc_use[i] == 0) { path.push_back(loc_number[i]); loc_use[i] = 1; add_path(); path.pop_back(); loc_use[i] = 0; } } } vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt; permuteUnique(vector\u0026lt;int\u0026gt;\u0026amp; nums) { loc_number = nums; sort(loc_number.begin(), loc_number.end()); loc_use = vector\u0026lt;int\u0026gt;(nums.size(), 0); add_path(); return result; } }; 这里 判断完last 后直接设置 last是不对的。[1,1,2]， 这个就无法正常输出了，\n出现错误的原因是 ： 不该在一开始就设置last， 在push后再设置last\n代码\rclass Solution { public: vector\u0026lt;int\u0026gt; path; vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt; result; vector\u0026lt;int\u0026gt; loc_use; vector\u0026lt;int\u0026gt; loc_number; void add_path() { if (path.size() == loc_number.size()) { result.push_back(path); return; } int last = 11; for (int i = 0; i \u0026lt; loc_number.size(); i++) { if (loc_number[i] == last) { continue; } if (loc_use[i] == 0) { last = loc_number[i]; path.push_back(loc_number[i]); loc_use[i] = 1; add_path(); path.pop_back(); loc_use[i] = 0; } } } vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt; permuteUnique(vector\u0026lt;int\u0026gt;\u0026amp; nums) { loc_number = nums; sort(loc_number.begin(), loc_number.end()); loc_use = vector\u0026lt;int\u0026gt;(nums.size(), 0); add_path(); return result; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-47-%E5%85%A8%E6%8E%92%E5%88%97-ii-%E5%9B%9E%E6%BA%AF%E8%A7%A3%E6%B3%95/","title":"LeetCode 47 全排列 II - 回溯解法"},{"content":"LeetCode 474 一和零\r🟡 中等\nhttps://leetcode.cn/problems/ones-and-zeroes/description/\n由递归转到动态规划。\nclass Solution { public: // int sel_not(vector\u0026lt;string\u0026gt;\u0026amp; strs, int index, int cap_m, int cap_n) { // if (index == strs.size()) { // return 0; // } // int one_nums = count(strs[index].begin(), strs[index].end(), \u0026#39;1\u0026#39;); // int zero_nums = strs[index].size() - one_nums; // if (zero_nums \u0026lt;= cap_m \u0026amp;\u0026amp; one_nums \u0026lt;= cap_n) { // int sel = // sel_not(strs, index + 1, cap_m - zero_nums, cap_n - one_nums); // int not_sel = sel_not(strs, index + 1, cap_m, cap_n); // return max(1 + sel, not_sel); // } else { // return sel_not(strs, index + 1, cap_m, cap_n); // } // } int findMaxForm(vector\u0026lt;string\u0026gt;\u0026amp; strs, int m, int n) { int dp[strs.size() + 1][m + 1][n + 1]; memset(dp, 0, sizeof dp); for (int i = strs.size() - 1; i \u0026gt;= 0; i--) { int one_nums = count(strs[i].begin(), strs[i].end(), \u0026#39;1\u0026#39;); int zero_nums = strs[i].size() - one_nums; for (int mm = 0; mm \u0026lt;= m; mm++) { for (int nn = 0; nn \u0026lt;= n; nn++) { if (zero_nums \u0026lt;= mm \u0026amp;\u0026amp; one_nums \u0026lt;= nn) { int sel = dp[i + 1][mm - zero_nums][nn - one_nums]; int not_sel = dp[i + 1][mm][nn]; dp[i][mm][nn] = max(1 + sel, not_sel); } else { dp[i][mm][nn] = dp[i + 1][mm][nn]; } } } } // return sel_not(strs, 0, m, n); return dp[0][m][n]; } }; 进一步优化空间，这里要注意m和n的遍历顺序，为了防止之前的变量污染，因此要倒序。\nclass Solution { public: // int sel_not(vector\u0026lt;string\u0026gt;\u0026amp; strs, int index, int cap_m, int cap_n) { // if (index == strs.size()) { // return 0; // } // int one_nums = count(strs[index].begin(), strs[index].end(), \u0026#39;1\u0026#39;); // int zero_nums = strs[index].size() - one_nums; // if (zero_nums \u0026lt;= cap_m \u0026amp;\u0026amp; one_nums \u0026lt;= cap_n) { // int sel = // sel_not(strs, index + 1, cap_m - zero_nums, cap_n - one_nums); // int not_sel = sel_not(strs, index + 1, cap_m, cap_n); // return max(1 + sel, not_sel); // } else { // return sel_not(strs, index + 1, cap_m, cap_n); // } // } int findMaxForm(vector\u0026lt;string\u0026gt;\u0026amp; strs, int m, int n) { int dp[m + 1][n + 1]; memset(dp, 0, sizeof dp); for (int i = strs.size() - 1; i \u0026gt;= 0; i--) { int one_nums = count(strs[i].begin(), strs[i].end(), \u0026#39;1\u0026#39;); int zero_nums = strs[i].size() - one_nums; for (int mm = m; mm \u0026gt;= 0; mm--) { for (int nn = n; nn \u0026gt;= 0; nn--) { if (zero_nums \u0026lt;= mm \u0026amp;\u0026amp; one_nums \u0026lt;= nn) { int sel = dp[mm - zero_nums][nn - one_nums]; int not_sel = dp[mm][nn]; dp[mm][nn] = max(1 + sel, not_sel); } else { dp[mm][nn] = dp[mm][nn]; } } } } // return sel_not(strs, 0, m, n); return dp[m][n]; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-474-%E4%B8%80%E5%92%8C%E9%9B%B6/","title":"LeetCode 474 一和零"},{"content":"LeetCode 491 递增子序列 - 回溯解法\rhttps://leetcode.cn/problems/non-decreasing-subsequences/description/\n思路\r和求子集问题类似，函数语义为：从index 到最后中选择一个数加入到path中。\nfor(int i = index;i\u0026lt;max;i++){ if(loc[i]合法){ 加入 addPath(i+1) pop } } 但是， 会有重复的问题， [4,6,7,7]\n所以还要去重，这里的重复是 你和兄弟们的重复 两个 467 重复了，在 for 循环遍历到 第一个7 后，第二个 7 就不该遍历， 针对这种可以借鉴之前[组合总和2](LeetCode 40 组合总和 II - 回溯解法.md) 设置 last 来存储上一次的值。\n数据范围\u0026lt;100 设置为101 int last = 101 for(int i = index;i\u0026lt;max;i++){ if(loc[i]==last) continue; if(loc[i]合法){ 加入 addPath(i+1) pop } } 提交后发现没通过，[4,6,7,7,4,4]\n这里 last=7 不起作用， 因此要设置 used 数组，每回加入的时候要查一下。\nuse[101] 全是101 for(int i = index;i\u0026lt;max;i++){ if(used(loc[i])) continue; if(loc[i]合法){ 加入 addPath(i+1) pop } } 代码\rclass Solution { public: vector\u0026lt;int\u0026gt; loc_nums; vector\u0026lt;int\u0026gt; path; vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt; result; bool is_used(vector\u0026lt;int\u0026gt; use, int value) { for (int i = 0; i \u0026lt; use.size(); i++) { if (use[i] == value) { return true; } } return false; } void add_path(int index) { if (path.size() \u0026gt;= 2) { result.push_back(path); } vector\u0026lt;int\u0026gt; use(loc_nums.size()-index,101); for (int i = index; i \u0026lt; loc_nums.size(); i++) { if (is_used(use, loc_nums[i])) continue; use.push_back(loc_nums[i]); if (path.size() == 0 || loc_nums[i] \u0026gt;= path.back()) { path.push_back(loc_nums[i]); add_path(i + 1); path.pop_back(); } } } vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt; findSubsequences(vector\u0026lt;int\u0026gt;\u0026amp; nums) { loc_nums = nums; add_path(0); return result; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-491-%E9%80%92%E5%A2%9E%E5%AD%90%E5%BA%8F%E5%88%97-%E5%9B%9E%E6%BA%AF%E8%A7%A3%E6%B3%95/","title":"LeetCode 491 递增子序列 - 回溯解法"},{"content":"LeetCode 494 目标和\r🟡中等\nhttps://leetcode.cn/problems/target-sum/\n显然可以用回溯求解\nclass Solution { public: vector\u0026lt;int\u0026gt; loc_nums; vector\u0026lt;int\u0026gt; path; int num =0; void wri_at_index(int index,int max_index,int sum, int target){ if(index==max_index){ if(sum==target){ num+=1; return; } return; } path.push_back(0+loc_nums[index]); wri_at_index(index+1, max_index,sum+loc_nums[index],target); path.pop_back(); path.push_back(0-loc_nums[index]); wri_at_index(index+1, max_index, sum-loc_nums[index],target); path.pop_back(); } int findTargetSumWays(vector\u0026lt;int\u0026gt;\u0026amp; nums, int target) { loc_nums = nums; wri_at_index(0,nums.size(),0,target); return num; } }; 但是，设原数组和为sum，加上正负号后的和为target，那么有：正+正 = sum，正+负 = target，则正 = （sum+target）/2，那么转化为背包问题。\nclass Solution { public: int add_pack(vector\u0026lt;int\u0026gt;\u0026amp; nums,int index,int cap){ if(cap ==0) return 1; if(index==nums.size()) { return 0; } if(cap\u0026gt;=nums[index]){ return add_pack(nums,index+1,cap)+add_pack(nums,index+1,cap-nums[index]); }else{ return add_pack(nums,index+1,cap); } } int findTargetSumWays(vector\u0026lt;int\u0026gt;\u0026amp; nums, int target) { int sum = accumulate(nums.begin(),nums.end(),0); if((sum+target)%2){ return 0; } return add_pack(nums,0,(sum+target)/2); } }; 这里出现了错误，cap等于0不能提前截断 ，这种针对本层输入的，一定要走到最后也就是index到最后才能返回，中途不能返回，\n修改后\nclass Solution { public: int add_pack(vector\u0026lt;int\u0026gt;\u0026amp; nums,int index,int cap){ // if(cap ==0) return 1; if(index==nums.size()) { if(cap==0) return 1; return 0; } if(cap\u0026gt;=nums[index]){ return add_pack(nums,index+1,cap)+add_pack(nums,index+1,cap-nums[index]); }else{ return add_pack(nums,index+1,cap); } } int findTargetSumWays(vector\u0026lt;int\u0026gt;\u0026amp; nums, int target) { int sum = accumulate(nums.begin(),nums.end(),0); if((sum+target)%2){ return 0; } return add_pack(nums,0,(sum+target)/2); } }; 进一步修改为动态规划\nclass Solution { public: int add_pack(vector\u0026lt;int\u0026gt;\u0026amp; nums,int index,int cap){ // if(cap ==0) return 1; if(index==nums.size()) { if(cap==0) return 1; return 0; } if(cap\u0026gt;=nums[index]){ return add_pack(nums,index+1,cap)+add_pack(nums,index+1,cap-nums[index]); }else{ return add_pack(nums,index+1,cap); } } int findTargetSumWays(vector\u0026lt;int\u0026gt;\u0026amp; nums, int target) { int sum = accumulate(nums.begin(),nums.end(),0); if((sum+target)%2||sum+target\u0026lt;0){ return 0; } int dp[(sum+target)/2+1]; memset(dp,0,sizeof dp); dp[0] = 1; for(int i = nums.size()-1;i\u0026gt;=0;i--){ for(int j =(sum+target)/2;j\u0026gt;=0;j--){ if(j\u0026gt;=nums[i]){ dp[j] = dp[j]+dp[j-nums[i]]; }else{ dp[j] = dp[j]; } } } // return add_pack(nums,0,(sum+target)/2); return dp[(sum+target)/2]; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-494-%E7%9B%AE%E6%A0%87%E5%92%8C/","title":"LeetCode 494 目标和"},{"content":"LeetCode 5 最长回文子串\r🟠 中等偏上 https://leetcode.cn/problems/longest-palindromic-substring/description/\n思路\r枚举中心点，向两边扩展。\nclass Solution { public: string longestPalindrome(string s) { int n = s.size(); int start = 0,end = 0; for(int i = 0;i\u0026lt;n;i++){ int len1 = help(s,i,i); int len2 = help(s,i,i+1); int len = max(len1,len2); if(len\u0026gt;end-start+1){ if(len%2==1){ start = i-1-(len-1)/2+1; end = i+1+(len-1)/2-1; }else{ start = i-(len)/2+1; end = i+1+(len)/2-1; } } } return s.substr(start,end-start+1); } int help(string\u0026amp; s,int left, int right){ while(left\u0026gt;=0\u0026amp;\u0026amp;right\u0026lt;s.size()\u0026amp;\u0026amp;s[left]==s[right]){ left--; right++; } // cout\u0026lt;\u0026lt;right-left+1\u0026lt;\u0026lt;endl; return right - left-1; } }; class Solution { public: int get_length(string \u0026amp;s,int left,int right){ int len = 0; while(left\u0026gt;=0\u0026amp;\u0026amp;right\u0026lt;s.size()){ if(s[left]==s[right]){ left--; right++; len++; }else{ break; } } return len; } string longestPalindrome(string s) { int n = s.size(); if(n==1) return s; int i = 0; int res_index = 0,res_length = 0; while(i\u0026lt;n){ int len1 = 2*get_length(s,i,i+1);//偶 int len2 = 1+2*get_length(s,i,i+2); //奇数 if(len1\u0026gt;len2\u0026amp;\u0026amp;len1\u0026gt;res_length){ res_index = i-len1/2+1; res_length = len1; } if(len2\u0026gt;len1\u0026amp;\u0026amp;len2\u0026gt;res_length){ res_index = i-(len2-1)/2+1; res_length = len2; } i++; } // cout\u0026lt;\u0026lt;res_index\u0026lt;\u0026lt;endl; return s.substr(res_index,res_length); } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-5-%E6%9C%80%E9%95%BF%E5%9B%9E%E6%96%87%E5%AD%90%E4%B8%B2/","title":"LeetCode 5 最长回文子串"},{"content":"LeetCode 51 N 皇后 - 回溯解法\rhttps://leetcode.cn/problems/n-queens/description/\n思路\rfor(0- n-1){ if(i 位置合法，可以放皇后){ path push add_path() path pop } } 终止条件\rif( path.size()==n) 判断合法\r要求：同一行 同一列只能有一个皇后， 斜线也只能有一个。\n分析：显然本行的皇后的位置受制于 path中已经加入的即：前面已经放置的皇后。\n当前位置的受影响的位置为：loc[i]-(path.size()-i) 和 loc[i]+(path.size()-i)\n合法为1，不合法为-1 vector\u0026lt;int\u0026gt; get_valid_loc(int n){ vector\u0026lt;int\u0026gt; res; for(inti \u0026lt;size()){ //同一列 res[loc[i]] = -1; int left = loc[i]-(path.size()-i) int right = loc[i]+(path.size()-i) if(left\u0026gt;=0) res[left] =-1; if(right\u0026lt;n) res[right] =-1; } return res; } 代码\rclass Solution { public: struct Path_node{ vector\u0026lt;string\u0026gt; path; vector\u0026lt;int\u0026gt; q_loc; }; Path_node path_node; vector\u0026lt;vector\u0026lt;string\u0026gt;\u0026gt; result; vector\u0026lt;int\u0026gt; get_val_loc(int n){ vector\u0026lt;int\u0026gt; res(n,1); for(int i = 0;i\u0026lt;path_node.path.size();i++){ res[path_node.q_loc[i]] = -1; int left = path_node.q_loc[i]-(path_node.path.size()-i); int right = path_node.q_loc[i]+(path_node.path.size()-i); if(left\u0026gt;=0){ res[left] = -1; } if(right\u0026lt;n){ res[right] = -1; } } return res; } void add_path(int n){ if(path_node.path.size()==n){ result.push_back(path_node.path); return; } vector\u0026lt;int\u0026gt; valid_loc = get_val_loc(n); for(int i = 0;i\u0026lt;n;i++){ if(valid_loc[i]==1){ string tmp(n,\u0026#39;.\u0026#39;); tmp[i] = \u0026#39;Q\u0026#39;; path_node.path.push_back(tmp); path_node.q_loc.push_back(i); add_path(n); path_node.path.pop_back(); path_node.q_loc.pop_back(); } } } vector\u0026lt;vector\u0026lt;string\u0026gt;\u0026gt; solveNQueens(int n) { add_path(n); return result; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-51-n-%E7%9A%87%E5%90%8E-%E5%9B%9E%E6%BA%AF%E8%A7%A3%E6%B3%95/","title":"LeetCode 51 N 皇后 - 回溯解法"},{"content":"LeetCode 518 零钱兑换 II\r🟡 中等\nhttps://leetcode.cn/problems/coin-change-ii/description/\n对于本层输入index 有选和不选，简单递归。\n注意 一定要等index 到最后才能返回，中途不能返回。 class Solution { public: int sel_not(vector\u0026lt;int\u0026gt;\u0026amp; coins, int index,int cap){ // if(cap==0) return 1; if(index==coins.size()){ if(cap==0) return 1; return 0; } if(cap\u0026gt;=coins[index]){ return sel_not(coins,index+1,cap)+sel_not(coins,index,cap-coins[index]); }else{ return sel_not(coins,index+1,cap); } } int change(int amount, vector\u0026lt;int\u0026gt;\u0026amp; coins) { return sel_not(coins,0,amount); } }; 改为动态规划\n内层循环本来是 0-amount，但是不满足if条件就什么也不做，因此直接将if 条件转到for循环上面。\nclass Solution { public: int sel_not(vector\u0026lt;int\u0026gt;\u0026amp; coins, int index, int cap) { // if(cap==0) return 1; if (index == coins.size()) { if (cap == 0) return 1; return 0; } if (cap \u0026gt;= coins[index]) { return sel_not(coins, index + 1, cap) + sel_not(coins, index, cap - coins[index]); } else { return sel_not(coins, index + 1, cap); } } int change(int amount, vector\u0026lt;int\u0026gt;\u0026amp; coins) { int dp[amount + 1]; memset(dp, 0, sizeof dp); dp[0] = 1; for (int i = coins.size() - 1; i \u0026gt;= 0; i--) { for (int j = coins[i]; j \u0026lt;=amount; j++) { // if (j \u0026gt;= coins[i]) { dp[j] = dp[j]+dp[j-coins[i]]; // } } } // return sel_not(coins, 0, amount); return dp[amount]; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-518-%E9%9B%B6%E9%92%B1%E5%85%91%E6%8D%A2-ii/","title":"LeetCode 518 零钱兑换 II"},{"content":"LeetCode 53 最大子数组和 - 动态规划解法\rhttps://leetcode.cn/problems/maximum-subarray/description/\n思路\r暴力穷举\rclass Solution { public: int maxSubArray(vector\u0026lt;int\u0026gt;\u0026amp; nums) { int res=INT_MIN; for(int index =0;index\u0026lt;nums.size();index++){ for(int len = 0;len+index\u0026lt;nums.size();len++){ int sum = 0; for(int j = index;j\u0026lt;=index+len;j++){ sum+=nums[j]; } res = max(sum,res); } } return res; } }; 动规\r第一次做这题时，动规思路并不清晰。复盘后可以这样理解：\n这题的答案不是 dp 数组最后一个位置的值，而是从所有状态中按规则（这里取最大值）选出来。因此，dp 数组（或递归函数）的语义本质是“状态分类”，关键是怎么分。\n这里的分类方式是：以 index 开头的子数组最大和。不同 index 之间存在递推关系，所以可以使用递归/动态规划。\n穷举所有情况-\u0026gt;分类-\u0026gt;加上最优性质-\u0026gt;各个类别之间产生关系。\n递归\rclass Solution { public: int res = INT_MIN; int get_max(vector\u0026lt;int\u0026gt; nums,int index){ if(index==nums.size()) return 0; int tmp = get_max(nums,index+1); if(tmp\u0026lt;0){ res = max(res,nums[index]); return nums[index]; }else{ res = max(res,nums[index]+tmp); return nums[index]+tmp; } } int maxSubArray(vector\u0026lt;int\u0026gt;\u0026amp; nums) { get_max(nums,0); return res; } }; 动规\rclass Solution { public: int maxSubArray(vector\u0026lt;int\u0026gt;\u0026amp; nums) { int max_value = INT_MIN; vector\u0026lt;int\u0026gt; re_value = vector\u0026lt;int\u0026gt;(nums.size()+1,INT_MIN); re_value[nums.size()] = 0; for(int i = nums.size()-1;i\u0026gt;=0;i--){ re_value[i+1]\u0026gt;0?re_value[i] = nums[i]+re_value[i+1]:re_value[i] = nums[i]; max_value = max(max_value,re_value[i]); } return max_value; } }; 发现for循环内 只依赖 i+1 ，因此可以只维护一个变量：last。\nclass Solution { public: int maxSubArray(vector\u0026lt;int\u0026gt;\u0026amp; nums) { int max_value = INT_MIN; int last =0; for(int i = nums.size()-1;i\u0026gt;=0;i--){ last\u0026gt;0?last = nums[i]+last:last = nums[i]; max_value = max(max_value,last); } return max_value; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-53-%E6%9C%80%E5%A4%A7%E5%AD%90%E6%95%B0%E7%BB%84%E5%92%8C-%E5%8A%A8%E6%80%81%E8%A7%84%E5%88%92%E8%A7%A3%E6%B3%95/","title":"LeetCode 53 最大子数组和 - 动态规划解法"},{"content":"LeetCode 53 最大子数组和 - 贪心解法\rhttps://leetcode.cn/problems/maximum-subarray/description/\n思路\r暴力\r穷举所有子数组，计算和。\n外层遍历 index，内层遍历 len。 最内层再计算 index 到 index + len 的区间和。 这个最内层可以继续优化掉。 class Solution { public: int maxSubArray(vector\u0026lt;int\u0026gt;\u0026amp; nums) { int res=INT_MIN; for(int index =0;index\u0026lt;nums.size();index++){ for(int len = 0;len+index\u0026lt;nums.size();len++){ int sum = 0; for(int j = index;j\u0026lt;=index+len;j++){ sum+=nums[j]; } res = max(sum,res); } } return res; } }; 优化后：\nclass Solution { public: int maxSubArray(vector\u0026lt;int\u0026gt; \u0026amp;nums) { int res = INT_MIN; for (int index = 0; index \u0026lt; nums.size(); index++) { int sum =0; for (int len = 0; len + index \u0026lt; nums.size(); len++) { sum+=nums[index+len]; res = max(sum,res); } } return res; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-53-%E6%9C%80%E5%A4%A7%E5%AD%90%E6%95%B0%E7%BB%84%E5%92%8C-%E8%B4%AA%E5%BF%83%E8%A7%A3%E6%B3%95/","title":"LeetCode 53 最大子数组和 - 贪心解法"},{"content":"LeetCode 55 跳跃游戏\r中等偏下\nhttps://leetcode.cn/problems/jump-game/description/?envType=study-plan-v2\u0026envId=top-interview-150\n思路\r从左向右遍历,维护preFar表示已经遍历的位置所能到达的最远下标,\r最后比较 preFar 和n-1的关系即可 class Solution { public boolean canJump(int[] nums) { int preFar = 0; // 遍历到 n-2 的位置, 检查preFar是否能到 n-1 即可 for (int i = 0; i \u0026lt; nums.length - 1; i++) { // 此时preFar是 i-1 之前的最远位置 if (preFar \u0026lt; i) { return false; } // 更新preFar, 包含i这个位置 preFar = Math.max(preFar, i + nums[i]); } return preFar \u0026gt;= nums.length - 1; } } ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-55-%E8%B7%B3%E8%B7%83%E6%B8%B8%E6%88%8F/","title":"LeetCode 55 跳跃游戏"},{"content":"LeetCode 62 不同路径\r🟢 简单\n无障碍物\rhttps://leetcode.cn/problems/unique-paths/\n显然递归\nclass Solution { public: int uniquePaths(int m, int n) { if(m==1\u0026amp;\u0026amp;n==1) return 1; if(n\u0026lt;0||m\u0026lt;0) return 0; return uniquePaths(m,n-1)+uniquePaths(m-1,n); } }; 改进为dp\nclass Solution { public: int uniquePaths(int m, int n) { int dp[m+1][n+1]; memset(dp,0,sizeof dp); dp[0][1] = 1; for(int i = 1;i\u0026lt;=m;i++){ for(int j = 1;j\u0026lt;=n;j++){ dp[i][j] = dp[i][j-1]+dp[i-1][j]; } } return dp[m][n]; } }; 发现for循环中只用到了上面一行和左边一个，因此\nclass Solution { public: int uniquePaths(int m, int n) { int dp[n+1]; memset(dp,0,sizeof dp); int left = 1; for(int i = 1;i\u0026lt;=m;i++){ for(int j = 1;j\u0026lt;=n;j++){ dp[j] = left+dp[j]; left = dp[j]; } left = 0; } return dp[n]; } }; 有障碍物\rhttps://leetcode.cn/problems/unique-paths-ii/\nclass Solution { public: int uniquePathsWithObstacles(vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt;\u0026amp; obstacleGrid) { int row = obstacleGrid.size(); int col = obstacleGrid[0].size(); int dp[row+1][col+1]; memset(dp,0,sizeof dp); dp[0][1] =1; for(int i = 1;i\u0026lt;=row;i++){ for(int j = 1;j\u0026lt;=col;j++){ dp[i][j] = dp[i-1][j]+dp[i][j-1]; if(obstacleGrid[i-1][j-1]==1) dp[i][j]=0; } } return dp[row][col]; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-62-%E4%B8%8D%E5%90%8C%E8%B7%AF%E5%BE%84/","title":"LeetCode 62 不同路径"},{"content":"LeetCode 70 爬楼梯\r🟢简单\nhttps://leetcode.cn/problems/climbing-stairs/\n容易想到如下代码：\nclass Solution { public: int climbStairs(int n) { if(n==0) return 1; if(n==1) return 1; return climbStairs(n-1)+climbStairs(n-2); } }; 备忘递归\nclass Solution { public: vector\u0026lt;int\u0026gt; dp; int re(int n) { if(n==0) return 1; if(n==1) return 1; if(dp[n]!=-1) return dp[n]; dp[n] = re(n-1)+re(n-2); return dp[n]; } int climbStairs(int n) { dp = vector\u0026lt;int\u0026gt;(n+1,-1); return re(n); } }; 改成动态规划\nclass Solution { public: int climbStairs(int n) { if(n\u0026lt;=1) return 1; int pre_pre = 1; int pre = 1; for(int i = 2;i\u0026lt;=n;i++){ int tmp = pre; pre = pre+pre_pre; pre_pre = tmp; } return pre; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-70-%E7%88%AC%E6%A5%BC%E6%A2%AF/","title":"LeetCode 70 爬楼梯"},{"content":"LeetCode 713 乘积小于 K 的子数组\r🟡 中等\nhttps://leetcode.cn/problems/subarray-product-less-than-k/description/\n注意到数组元素都是大于1的， 因此一旦一个子数组的乘积小于了target， 那么其子数组都是满足的。\n每层记录的是: 以right 结尾的所有答案数组个数， 会不会少呢? left 到 right 之间满足条件的不一定要以right 结尾， 那些在这个区间内但是不以right结尾的数组会不会被漏掉了呢? 其实不会， 因为所有数组的右侧结束位置都会被遍历一遍， 和最大子数组和的问题相似， 最大子数组和是枚举开始位置， 这里是枚举的结束位置， 一定不会漏掉.\nclass Solution { public: int numSubarrayProductLessThanK(vector\u0026lt;int\u0026gt;\u0026amp; nums, int k) { if(k\u0026lt;=1) return 0; //枚举右端点 int ans = 0; int prod = 1; int left = 0; for(int r = 0;r\u0026lt;nums.size();r++){ prod *= nums[r]; while(prod\u0026gt;=k\u0026amp;\u0026amp;r\u0026gt;=left){ prod /=nums[left]; left++; } ans+=r-left+1; } return ans; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-713-%E4%B9%98%E7%A7%AF%E5%B0%8F%E4%BA%8E-k-%E7%9A%84%E5%AD%90%E6%95%B0%E7%BB%84/","title":"LeetCode 713 乘积小于 K 的子数组"},{"content":"LeetCode 718 最长重复子数组\r🟡中等 https://leetcode.cn/problems/maximum-length-of-repeated-subarray/description/\n类似于 [LCR095最长子序列](LCR 095 最长公共子序列.md) ， 这里有一些区别。\n先尝试思考递归，和最长子序列类似，但含义却是从两个index开始的相同子数组的长度。\nclass Solution { public: int findLength(vector\u0026lt;int\u0026gt;\u0026amp; nums1, vector\u0026lt;int\u0026gt;\u0026amp; nums2) { int max_len = 0; // 遍历 nums1 和 nums2 的每个位置，尝试找到从这些位置开始的最长公共子数组 for (int i = 0; i \u0026lt; nums1.size(); i++) { for (int j = 0; j \u0026lt; nums2.size(); j++) { max_len = max(max_len, maxLengthFrom(nums1, i, nums2, j)); } } return max_len; } private: int maxLengthFrom(vector\u0026lt;int\u0026gt;\u0026amp; nums1, int i, vector\u0026lt;int\u0026gt;\u0026amp; nums2, int j) { // 基本条件：如果任何一个数组越界，则无法继续 if (i \u0026gt;= nums1.size() || j \u0026gt;= nums2.size()) return 0; // 如果当前元素相同，则递归继续查找下一个元素 if (nums1[i] == nums2[j]) { return 1 + maxLengthFrom(nums1, i + 1, nums2, j + 1); } else { // 如果当前元素不同，则子数组无法延续，返回 0 return 0; } } }; 修改成dp， 发现每个函数调用依赖于右下的值，因此，dp更新从下往上，如果使用二维dp数组的话左右没区别。\n但是观察发现只依赖于下一层的调用，因此可以优化空间。这时，上下还是从下到上，但是必须从左到右。\nclass Solution { public: int findLength(vector\u0026lt;int\u0026gt;\u0026amp; nums1, vector\u0026lt;int\u0026gt;\u0026amp; nums2) { vector\u0026lt;int\u0026gt; dp(nums2.size()+1,0); int res = 0; for(int row = nums1.size()-1;row\u0026gt;=0;row--){ for(int col = 0;col\u0026lt;nums2.size();col++){ if(nums1[row]==nums2[col]) dp[col] = 1+dp[col+1]; else{ dp[col] = 0; } res = max(res,dp[col]); } } return res; // return add_path(nums1,0,nums2,0); } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-718-%E6%9C%80%E9%95%BF%E9%87%8D%E5%A4%8D%E5%AD%90%E6%95%B0%E7%BB%84/","title":"LeetCode 718 最长重复子数组"},{"content":"LeetCode 73 矩阵置零 - 原地算法\rhttps://leetcode.cn/problems/set-matrix-zeroes/description/?envType=study-plan-v2\u0026envId=top-interview-150\n思路\r目标是判断每一行、每一列是否需要置零。常规做法需要 m + n 的额外空间，但题目要求原地修改，所以要复用矩阵本身做标记。\n某一行一旦出现 0，该行最终都会被置零，因此可以用该行第一个元素作为行标记。 同理，可用每一列的第一个元素作为列标记。 另外用两个额外变量记录第一行和第一列是否原本含 0，避免标记过程覆盖原信息。 如果第 i 行原本没有 0，那么该行第一个元素不会被错误覆盖，原数组信息可以被正确保留。\n","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-73-%E7%9F%A9%E9%98%B5%E7%BD%AE%E9%9B%B6-%E5%8E%9F%E5%9C%B0%E7%AE%97%E6%B3%95/","title":"LeetCode 73 矩阵置零 - 原地算法"},{"content":"LeetCode 75 颜色分类\r🟡 中等 https://leetcode.cn/problems/sort-colors/description/\nlast数组\r思路：维护last数组记录 0 1 2 的上次位置，对于1首先检查last[1] 为-1的话就再检查 last[0] 的值；对于数字num，依次向前检查last[num] last[num-1] ，如果都是 -1 就返回-1。\n每次找到last位置后交换，交换完还要继续检查交换后的当前位置的值是否在对应的last位置后面。\n内层while 循环至多执行两次，总共O(n)。\nclass Solution { public: int should_index(vector\u0026lt;int\u0026gt;\u0026amp; nums,int num){ for(int i = num;i\u0026gt;=0;i--){ if(nums[i]!=-1) return nums[i]; } return -1; } void sortColors(vector\u0026lt;int\u0026gt;\u0026amp; nums) { vector\u0026lt;int\u0026gt; last(3,-1); for(int i = 0;i\u0026lt;nums.size();i++){ int last_index = should_index(last,nums[i]); while(last_index+1!=i){ last[nums[i]] = last_index+1; swap(nums[i],nums[last_index+1]); last_index = should_index(last,nums[i]); } last[nums[i]] = last_index+1; } } }; 经典双指针\r在 nums[i]==0 时为什么要 i++，因为：为0时交换过来的是 0或1，所以要i++，当nums[i] = 2时不用i++，因为交换过来的可能为 0 1 2 要继续判断。\nclass Solution { public: void sortColors(vector\u0026lt;int\u0026gt;\u0026amp; nums) { int n = nums.size(); int p0 = 0; int p2 = n - 1; int i = 0; while(i\u0026lt;=p2){ if(nums[i]==0){ swap(nums[i],nums[p0]); p0++; i++; }else if(nums[i]==2){ swap(nums[i],nums[p2]); p2--; }else{ i++; } } } }; 或者自己写的好理解版本\nclass Solution { public: void sortColors(vector\u0026lt;int\u0026gt;\u0026amp; nums) { int n = nums.size(); int p0 = 0; int p2 = n - 1; for (int i = 0; i \u0026lt; n; i++) { while(i\u0026lt;=p2){ if(nums[i]==1) break; if(nums[i]==0){ if(i==p0) break; swap(nums[i],nums[p0]); p0++; } if(nums[i]==2){ if(i==p2) break; swap(nums[i],nums[p2]); p2--; } } } } }; 或者\nclass Solution { public: void sortColors(vector\u0026lt;int\u0026gt;\u0026amp; nums) { int n = nums.size(); int p0 = 0; int p2 = n - 1; int i = 0; while(i\u0026lt;=p2){ if(nums[i]==0){ if(nums[p0]==0){ i++; p0++; continue; } swap(nums[i],nums[p0]); p0++; // i++; }else if(nums[i]==2){ swap(nums[i],nums[p2]); p2--; }else{ i++; } } } }; 刷油漆\rclass Solution { public: void sortColors(vector\u0026lt;int\u0026gt;\u0026amp; nums) { int n = nums.size(); int n0 = 0; int n1 = 0; int i = 0; while(i\u0026lt;n){ int num = nums[i]; nums[i] = 2; if(num\u0026lt;2){ nums[n1]=1; n1++; } if(num\u0026lt;1){ nums[n0] = 0; n0++; } i++; } } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-75-%E9%A2%9C%E8%89%B2%E5%88%86%E7%B1%BB/","title":"LeetCode 75 颜色分类"},{"content":"LeetCode 77 组合 - 回溯解法\rhttps://leetcode.cn/problems/combinations/description/\n思路\r这个是搜索算法， 递归函数没有返回值，所以要控制全局变量，而深层次的调用就不是取值了， 而是做某件事情.\n一开始path为空， 选数加入path， 这里为1， 然后再从1后面的里面选数加入path. 不断重复上述过程. 所以函数可以理解为 : 不断地从某一区间选值加入到path中. 这是一件事而非求值.\n定义全局变量 path 表示已经得到的组合， result为最终返回的数组. 递归函数的语义是: 从start到max选一个数加入path， 或往path中加入新的值 . 那么， 当path的长度达到要求的长度时直接返回， 否则继续往path中加入新的值.\n直接返回. path的长度达到要求的值. 进一步调用. 本层从start到max中选个数加入到path中， 有很多情况， 要用for 循环遍历， 循环内: path中加入i， (再从i+1到max中选个数加入到path中)是下一层调用. 可以看到， 求解递归问题时可以先手动模拟得到 不变的流程， 上述题目中为 \u0026ldquo;不断地从某一区间选一个数加入到path中\u0026rdquo;， 这个不变的流程就是递归函数的语义.\nfunction add_path(start , max){ //从start 到max选值加入到path中. if(path.size()==) result.add(path) return // 进一步调用 // 显然, 一开始往path中加入的值有1-n, n种情况 ,这里1应作为参数传入, 后续为i-n 个情况 // 每种情况都要遍历到位, for循环 for(i=start, i\u0026lt;=max, i++){ path.push_back(i) //加入i本层选的数 // 加入i后 ,再选数加入path时, 就要选i后面的数了. add_path(i+1,max) //从i+1到max 选数加入 // 因为用了for循环且path为全局变量, 因此要及时清理path种的值 path.pop_back() } } 代码\rclass Solution { public: vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt; result; vector\u0026lt;int\u0026gt; path; void search(int max,int start,int num_k){ //意为从start 到max选值加入path if(path.size()==num_k){ result.push_back(path); return; } for(int i =start;i\u0026lt;=max;i++){ //从start 开始遍历 path.push_back(i); //加入到path中 search(max,i+1,num_k); //从下一位到max选值加入到path中. path.pop_back(); //清理全局变量path } } vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt; combine(int n, int k) { search(n,1,k); return result; } }; 选或不选\r这里没有剪枝。\nclass Solution { public: vector\u0026lt;int\u0026gt; path; vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt; result; void sel_or_not(int i,int k,int n){ if(path.size()==k){ result.push_back(path); return; } if(i\u0026gt;n) return; path.push_back(i); sel_or_not(i+1,k,n); path.pop_back(); sel_or_not(i+1,k,n); } vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt; combine(int n, int k) { sel_or_not(1,k,n); return result; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-77-%E7%BB%84%E5%90%88-%E5%9B%9E%E6%BA%AF%E8%A7%A3%E6%B3%95/","title":"LeetCode 77 组合 - 回溯解法"},{"content":"LeetCode 78 子集 - 回溯解法\rhttps://leetcode.cn/problems/subsets/description/\n答案角度：for循环。\r与 组合问题不太一样, 不变的流程: 从index到最后选一个值(index为 i )加入path， 接着再从 i+1 到最后选一个值加入 path.\nfor(int i = index; i\u0026lt;size; i++){ path.push(loc[i]) //再从 i+1 到 最后 选值加入 add_path(i+1); path.pop(); } 👍 特殊的点: 这里result 保存的条件，因为每个子集都要加入， 只要path长度大于1， 就要加入 result， 但是不要 return(以往的题都是直接return，不继续加path了，)， 这里还要继续遍历.\n那么，递归算法不return 会无限执行下去吗? 不会，\n最后一层， 里面的for 循环不会执行， 所以不会继续进行深层次的调用， 所以直接就返回上层了. 递归函数不一定要有 return 表达式，没有也可以不无限调用.\nclass Solution { public: vector\u0026lt;int\u0026gt; loc_nums; vector\u0026lt;int\u0026gt; path; vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt; result; void add_path(int index){ if(path.size()\u0026gt;0){ result.push_back(path); } for(int i = index;i\u0026lt;loc_nums.size();i++){ path.push_back(loc_nums[i]); add_path(i+1); path.pop_back(); } } vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt; subsets(vector\u0026lt;int\u0026gt;\u0026amp; nums) { loc_nums = nums; add_path(0); vector\u0026lt;int\u0026gt; null_vec; result.push_back(null_vec); return result; } }; 本层输入角度：选或不选\r对于一层的输入 index，本层都可以选或不选。\nclass Solution { public: vector\u0026lt;int\u0026gt; loc_nums; vector\u0026lt;int\u0026gt; path; vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt; result; void add_path(int index){ //判断终点和之前的不一样，这里要走到尽头才能保存结果。 if(index==loc_nums.size()){ result.push_back(path); return; } //选 path.push_back(loc_nums[index]); add_path(index+1); path.pop_back(); //不选 add_path(index+1); } vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt; subsets(vector\u0026lt;int\u0026gt;\u0026amp; nums) { loc_nums = nums; add_path(0); vector\u0026lt;int\u0026gt; null_vec; // result.push_back(null_vec); return result; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-78-%E5%AD%90%E9%9B%86-%E5%9B%9E%E6%BA%AF%E8%A7%A3%E6%B3%95/","title":"LeetCode 78 子集 - 回溯解法"},{"content":"LeetCode 82 删除排序链表中的重复元素 II\r🟠 中等 https://leetcode.cn/problems/remove-duplicates-from-sorted-list-ii/description/\n首先第一个节点也可能被删，因此往前面加一个fakeHead；\n链表可以很方便的检查 fast-\u0026gt;next-\u0026gt;val==val\n/** * Definition for singly-linked list. * struct ListNode { * int val; * ListNode *next; * ListNode() : val(0), next(nullptr) {} * ListNode(int x) : val(x), next(nullptr) {} * ListNode(int x, ListNode *next) : val(x), next(next) {} * }; */ class Solution { public: ListNode* deleteDuplicates(ListNode* head) { if(head==nullptr) return head; ListNode* fake_head = new ListNode(); fake_head-\u0026gt;next = head; ListNode* slow = fake_head; ListNode* fast = head; int last = slow-\u0026gt;next-\u0026gt;val; while(fast\u0026amp;\u0026amp;fast-\u0026gt;next!=nullptr){ int val = fast-\u0026gt;val; int count = 0; while(fast-\u0026gt;next\u0026amp;\u0026amp;fast-\u0026gt;next-\u0026gt;val==val){ fast = fast-\u0026gt;next; count++; } if(count==0){ fast = fast-\u0026gt;next; slow = slow-\u0026gt;next; }else{ fast = fast-\u0026gt;next; slow-\u0026gt;next = fast; } } return fake_head-\u0026gt;next; } }; 官解\r具体地，我们从指针 cur 指向链表的哑节点，随后开始对链表进行遍历。如果当前 cur.next 与 cur.next.next 对应的元素相同，那么我们就需要将 cur.next 以及所有后面拥有相同元素值的链表节点全部删除。我们记下这个元素值 x，随后不断将 cur.next 从链表中移除，直到 cur.next 为空节点或者其元素值不等于 x 为止。此时，我们将链表中所有元素值为 x 的节点全部删除。\n如果当前 cur.next 与 cur.next.next 对应的元素不相同，那么说明链表中只有一个元素值为 cur.next 的节点，那么我们就可以将 cur 指向 cur.next。\n/** * Definition for singly-linked list. * struct ListNode { * int val; * ListNode *next; * ListNode() : val(0), next(nullptr) {} * ListNode(int x) : val(x), next(nullptr) {} * ListNode(int x, ListNode *next) : val(x), next(next) {} * }; */ class Solution { public: ListNode* deleteDuplicates(ListNode* head) { ListNode* fake_head = new ListNode(); fake_head-\u0026gt;next = head; ListNode* cur = fake_head; while(cur!=nullptr){ if(cur-\u0026gt;next\u0026amp;\u0026amp;cur-\u0026gt;next-\u0026gt;next\u0026amp;\u0026amp;cur-\u0026gt;next-\u0026gt;val==cur-\u0026gt;next-\u0026gt;next-\u0026gt;val){ int x = cur-\u0026gt;next-\u0026gt;val; while(cur-\u0026gt;next\u0026amp;\u0026amp;cur-\u0026gt;next-\u0026gt;val==x){ cur-\u0026gt;next = cur-\u0026gt;next-\u0026gt;next; } }else{ cur = cur-\u0026gt;next; } } return fake_head-\u0026gt;next; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-82-%E5%88%A0%E9%99%A4%E6%8E%92%E5%BA%8F%E9%93%BE%E8%A1%A8%E4%B8%AD%E7%9A%84%E9%87%8D%E5%A4%8D%E5%85%83%E7%B4%A0-ii/","title":"LeetCode 82 删除排序链表中的重复元素 II"},{"content":"LeetCode 84 柱状图中最大的矩形 - 单调栈解法\rhttps://leetcode.cn/problems/largest-rectangle-in-histogram/description/?envType=study-plan-v2\u0026envId=top-100-liked\n说明\r下面保留了两种写法：\n第一段是枚举区间的思路。 第二段是单调栈优化写法，核心是“在弹栈时结算面积”。 class Solution { public int largestRectangleArea(int[] heights) { int n = heights.length; int ans = 0; // 枚举 [i,j] for (int i = 0; i \u0026lt; n; i++) { int min = Integer.MAX_VALUE; int area = 0; for (int j = i; j \u0026lt; n; j++) { if (heights[j] \u0026lt; min) { min = heights[j]; area = (j - i + 1) * min; } else { area += min; } ans = Math.max(ans, area); } } return ans; } public int largestRectangleArea1(int[] heights) { int n = heights.length; int ans = 0; // 递增栈 Deque\u0026lt;Integer\u0026gt; deque = new LinkedList\u0026lt;\u0026gt;(); for(int i = 0;i\u0026lt;n;i++){ while (!deque.isEmpty()\u0026amp;\u0026amp;heights[i]\u0026lt;heights[deque.peek()]) { int top = deque.pop(); // height[i]\u0026lt;height[top], 可以计算 高为 height[top] 的矩形 // 左边界是弹出后栈顶, 有边界是height[i] int left = -1; if (!deque.isEmpty()) { left = deque.peek(); } int area = (i-1-(left+1)+1)*heights[top]; ans = Math.max(ans, area); } deque.push(i); } // 可能栈中还有 while (!deque.isEmpty()) { int top = deque.pop(); // 右侧找不到比 height[top]小的, 因此 right = n; int right = n; int left = -1; if (!deque.isEmpty()) { left = deque.peek(); } int area = (right-1-(left+1)+1)*heights[top]; ans = Math.max(ans, area); } return ans; } } 单调栈\r遇到相等高度时继续入栈。因为本题在弹栈时计算面积，要保证每个位置都能被结算一次。 ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-84-%E6%9F%B1%E7%8A%B6%E5%9B%BE%E4%B8%AD%E6%9C%80%E5%A4%A7%E7%9A%84%E7%9F%A9%E5%BD%A2-%E5%8D%95%E8%B0%83%E6%A0%88%E8%A7%A3%E6%B3%95/","title":"LeetCode 84 柱状图中最大的矩形 - 单调栈解法"},{"content":"LeetCode 86 分割链表\r🟡 中等 https://leetcode.cn/problems/partition-list/description/\n使用cur从链表中去掉大于等于x的节点。将去掉的节点使用尾插法 构建新链表，最后拼接。\nwhile 条件是 ：while(cur-\u0026gt;next!=nullptr){ ，而非 cur!=nullptr 因为下面会执行 cur = cur-\u0026gt;next 的操作。我们要最后 cur 停留在最后一个，并且while中会执行 ++ 操作，因此判断条件中要”提前“终止。 cur-\u0026gt;next = cur-\u0026gt;next-\u0026gt;next; 和 big_end-\u0026gt;next = nullptr; 顺序不能反；如果先执行后面的 那么 执行后 cur-\u0026gt;next-\u0026gt;next 就是 nullptr 了，这就丢失了原本的 cur-\u0026gt;next-\u0026gt;next 。 /** * Definition for singly-linked list. * struct ListNode { * int val; * ListNode *next; * ListNode() : val(0), next(nullptr) {} * ListNode(int x) : val(x), next(nullptr) {} * ListNode(int x, ListNode *next) : val(x), next(next) {} * }; */ class Solution { public: ListNode* partition(ListNode* head, int x) { ListNode* fake_head = new ListNode(); fake_head-\u0026gt;next = head; ListNode* cur = fake_head; ListNode* big_front = new ListNode(); ListNode* big_end = big_front; while(cur-\u0026gt;next!=nullptr){ if(cur-\u0026gt;next-\u0026gt;val\u0026gt;=x){ big_end-\u0026gt;next = cur-\u0026gt;next; big_end = big_end-\u0026gt;next; cur-\u0026gt;next = cur-\u0026gt;next-\u0026gt;next; big_end-\u0026gt;next = nullptr; }else{ cur = cur-\u0026gt;next; } } cur-\u0026gt;next = big_front-\u0026gt;next; return fake_head-\u0026gt;next; // return big_front-\u0026gt;next; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-86-%E5%88%86%E5%89%B2%E9%93%BE%E8%A1%A8/","title":"LeetCode 86 分割链表"},{"content":"LeetCode 90 子集 II - 回溯解法\rhttps://leetcode.cn/problems/subsets-ii/description/\n和 [组合总和2](LeetCode 40 组合总和 II - 回溯解法.md) 类似.\n思路\r仍然是 从index 到最后选一个数 i 加入path，再从 i 之后到最后选一个数加入到path中,\n只不过这里需要去重， 方法和 [组合总和2](LeetCode 40 组合总和 II - 回溯解法.md) 一样， 这里的重复是for 循环时 所取的 loc_nums[i] 与上一个重复了， 所以设置 last 标记上一次的选值， 当本次选的和上一次选的值重复时就跳过(这里就要求排序了).\nlast = 11 题目说 数值大小不会超过10, 这里取一个不会取到的值 for(int i = index; i\u0026lt;max;i++){ if(loc[i]==last) continue; path.push(loc[i]) add_path(i+1); path.pop() } 代码\rclass Solution { public: vector\u0026lt;int\u0026gt; loc_nums; vector\u0026lt;int\u0026gt; path; vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt; result; void add_path(int index){ if(path.size()\u0026gt;0) result.push_back(path); int last = 11; for(int i = index;i\u0026lt;loc_nums.size();i++){ if(loc_nums[i]==last) continue; last = loc_nums[i]; path.push_back(loc_nums[i]); add_path(i+1); path.pop_back(); } } vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt; subsetsWithDup(vector\u0026lt;int\u0026gt;\u0026amp; nums) { loc_nums = nums; sort(loc_nums.begin(), loc_nums.end()); add_path(0); vector\u0026lt;int\u0026gt; tmp; result.push_back(tmp); return result; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-90-%E5%AD%90%E9%9B%86-ii-%E5%9B%9E%E6%BA%AF%E8%A7%A3%E6%B3%95/","title":"LeetCode 90 子集 II - 回溯解法"},{"content":"LeetCode 93 复原 IP 地址 - 回溯解法\rhttps://leetcode.cn/problems/restore-ip-addresses/description/\n与[分割回文串](LeetCode 131 分割回文串 - 回溯解法.md) 类似， 不断的从前面拿到一个 合法的 **ip加入到path中， 直到走到最后. **\n每回拿到的长度应该为 1-3 for(int len = 1;len\u0026lt;=3;len++){ 这里可能 index+len\u0026gt;max 也要限制一下 left = loc.substr(index,len) if(left 合法){ path.push(left) add_path(index+len) path.pop(); } } 返回条件和 回文串 一样 当前面都加到 path 中后, 此时就应该返回了. 代码\rclass Solution { public: vector\u0026lt;string\u0026gt; path; vector\u0026lt;string\u0026gt; result; string loc_string; void add_path(int index) { if (path.size() \u0026gt; 4) return; if (index == loc_string.size() \u0026amp;\u0026amp; path.size() == 4) { string tmp = path[0]; for (int i = 1; i \u0026lt; 4; ++i) { tmp += \u0026#39;.\u0026#39; + path[i]; } result.push_back(tmp); return; } for (int len = 1; len \u0026lt;= 3 \u0026amp;\u0026amp; index + len \u0026lt;= loc_string.size(); len++) { string left = loc_string.substr(index, len); if (left[0] == \u0026#39;0\u0026#39; \u0026amp;\u0026amp; len \u0026gt; 1) { // 这里 一旦触发 continue 后面的循环一定会continue, 可以直接 break. continue; } if(stoi(left)\u0026gt;255) break; path.push_back(left); add_path(index + len); path.pop_back(); } } vector\u0026lt;string\u0026gt; restoreIpAddresses(string s) { loc_string = s; add_path(0); return result; } }; 总结\r👍 将[\u0026quot;225\u0026quot;,\u0026quot;2\u0026quot;,\u0026quot;33\u0026quot;,\u0026quot;55\u0026quot;]转换为225.2.33.55 时， 使用循环直接套四层循环不够美观， 注意到需要额外处理的位置只会出现在首尾， 因此可以直接令tmp 等于首部， 这样少一层循环且美观.\n👍 处理break. 当第一个数为0时， 接下来的循环都是应该跳过的， 因此直接break; 当左边大于255时， 接下来的循环也一定大于255，因此直接break.\n","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-93-%E5%A4%8D%E5%8E%9F-ip-%E5%9C%B0%E5%9D%80-%E5%9B%9E%E6%BA%AF%E8%A7%A3%E6%B3%95/","title":"LeetCode 93 复原 IP 地址 - 回溯解法"},{"content":"LeetCode 930 和相同的二元子数组 - 滑动窗口解法\r🟡 中等 https://leetcode.cn/problems/binary-subarrays-with-sum/description/\n思路\r首先长度无限制，但是窗口内的元素和有限制。枚举右端点，左侧遍历保存结果。\n左侧如果是0，那么需要找到第一个1。\nclass Solution { public: int numSubarraysWithSum(vector\u0026lt;int\u0026gt;\u0026amp; nums, int goal) { int left = 0; int sum = 0; int res = 0; for (int i = 0; i \u0026lt; nums.size(); i++) { sum += nums[i]; while (sum \u0026gt; goal \u0026amp;\u0026amp; left \u0026lt; i) { sum -= nums[left]; left++; } if (sum == goal) { //找到第一个1 int tmp = left; while (nums[tmp] == 0 \u0026amp;\u0026amp; tmp \u0026lt; i) { tmp++; } res += (tmp - left + 1); } } return res; } }; 注意到，每次左侧要向右试探性的遍历，因此，可以使用更加简洁的方法 前缀和\n用hash_map记录前缀和出现的次数，每次向右扩展一个新元素时就检查 hash_map[current_sum-goal] 的次数，加到res 中。\nclass Solution { public: int numSubarraysWithSum(vector\u0026lt;int\u0026gt;\u0026amp; nums, int goal) { unordered_map\u0026lt;int, int\u0026gt; hash_map; int sum1 = 0; int res = 0; for (int i = 0; i \u0026lt; nums.size(); i++) { hash_map[sum1]++; sum1 += nums[i]; res += hash_map[sum1 - goal]; } return res; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-930-%E5%92%8C%E7%9B%B8%E5%90%8C%E7%9A%84%E4%BA%8C%E5%85%83%E5%AD%90%E6%95%B0%E7%BB%84-%E6%BB%91%E5%8A%A8%E7%AA%97%E5%8F%A3%E8%A7%A3%E6%B3%95/","title":"LeetCode 930 和相同的二元子数组 - 滑动窗口解法"},{"content":"LeetCode 930 和相同的二元子数组 - 前缀和解法\r🟡 中等 https://leetcode.cn/problems/binary-subarrays-with-sum/description/\n思路\r可以用滑动窗口解决：[19-930和相同的二元子数组](../02 滑动窗口/LeetCode 930 和相同的二元子数组 - 滑动窗口解法.md)。\n这里记录的是前缀和写法，整体更直接。\nclass Solution { public: int numSubarraysWithSum(vector\u0026lt;int\u0026gt;\u0026amp; nums, int goal) { unordered_map\u0026lt;int, int\u0026gt; hash_map; int sum1 = 0; int res = 0; for (int i = 0; i \u0026lt; nums.size(); i++) { hash_map[sum1]++; sum1 += nums[i]; res += hash_map[sum1 - goal]; } return res; } }; 小tips\r上面的代码通过“先记录旧前缀和，再更新新前缀和”来处理 0 前缀。如果改成提前设定 hash_map[0] = 1，则通常要配合显式查找逻辑，例如：\n#include \u0026lt;vector\u0026gt; #include \u0026lt;unordered_map\u0026gt; using namespace std; class Solution { public: int numSubarraysWithSum(vector\u0026lt;int\u0026gt;\u0026amp; nums, int goal) { unordered_map\u0026lt;int, int\u0026gt; hash_map; hash_map[0] = 1; // Handle the case where the sum itself is equal to goal int sum1 = 0; int res = 0; for (int num : nums) { sum1 += num; if (hash_map.find(sum1 - goal) != hash_map.end()) { res += hash_map[sum1 - goal]; } hash_map[sum1]++; } return res; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-930-%E5%92%8C%E7%9B%B8%E5%90%8C%E7%9A%84%E4%BA%8C%E5%85%83%E5%AD%90%E6%95%B0%E7%BB%84-%E5%89%8D%E7%BC%80%E5%92%8C%E8%A7%A3%E6%B3%95/","title":"LeetCode 930 和相同的二元子数组 - 前缀和解法"},{"content":"LeetCode 978 最长湍流子数组\r🟡 中等 https://leetcode.cn/problems/longest-turbulent-subarray/description/\n子数组思考动态规划，分类：dp是问题的答案吗？不是，dp的含义应该是以i结尾的子数组的湍流长度。\n这里是按结尾分，显然只需要维护两个变量即可：前一个位置的count和方向。\nclass Solution { public: int maxTurbulenceSize(vector\u0026lt;int\u0026gt;\u0026amp; arr) { if (arr.size() == 1) return 1; int maxLength = 1; int currentLength = 1; int direction = 0; // 0: 未定义, 1: 增加, -1: 减少 for (int i = 1; i \u0026lt; arr.size(); ++i) { if(direction\u0026lt;0\u0026amp;\u0026amp;arr[i]-arr[i-1]\u0026gt;0||direction\u0026gt;0\u0026amp;\u0026amp;arr[i]-arr[i-1]\u0026lt;0){ currentLength = 1+currentLength; direction = arr[i]\u0026gt;arr[i-1]?1:-1; }else if(arr[i]==arr[i-1]){ currentLength = 1; direction = 0; }else{ currentLength = 2; direction = arr[i]\u0026gt;arr[i-1]?1:-1; } maxLength = max(maxLength, currentLength); } return maxLength; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-978-%E6%9C%80%E9%95%BF%E6%B9%8D%E6%B5%81%E5%AD%90%E6%95%B0%E7%BB%84/","title":"LeetCode 978 最长湍流子数组"},{"content":"LeetCode Java 常用函数\r基础对照\r用法 适用对象 示例 size() 集合类 list.size() length() 字符串 \u0026quot;abc\u0026quot;.length() length 数组 array.length 拷贝除了最后一个元素的所有内容\rList\u0026lt;String\u0026gt; copy = new ArrayList\u0026lt;\u0026gt;(original.subList(0, original.size() - 1)); 数组降序\r// citations = Arrays.stream(citations) // .boxed() // .sorted(( o1, o2) -\u0026gt;{ // return o2 - o1; // } // ) // .mapToInt(value-\u0026gt; { // return value.intValue(); // } // ) // .toArray(); ArrayList 副本\r// 使用构造函数创建副本 ArrayList\u0026lt;String\u0026gt; copyList = new ArrayList\u0026lt;\u0026gt;(originalList); ArrayList\u0026lt;String\u0026gt; copyList = new ArrayList\u0026lt;\u0026gt;(); copyList.addAll(originalList); 二维数组\rArrayList\u0026lt;ArrayList\u0026lt;Integer\u0026gt;\u0026gt; list = new ArrayList\u0026lt;\u0026gt;(10); list.add(new ArrayList\u0026lt;\u0026gt;(Arrays.asList(1,2,3))); list.add(new ArrayList\u0026lt;\u0026gt;(Arrays.asList(4,5,6))); list.add(new ArrayList\u0026lt;\u0026gt;(Arrays.asList(7,8,9))); System.out.println(list); ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-java-%E5%B8%B8%E7%94%A8%E5%87%BD%E6%95%B0/","title":"LeetCode Java 常用函数"},{"content":"LeetCode Java 相关题目\r交替打印\r思路分析：类的核心状态是 arr、线程数量、数组下标 cursor、当前线程 id（循环更新）。 public class 交替打印数组 { public static void main(String[] args) { int[] arr= new int[]{1,2,3,4,5,6,7,8,9}; int m = 3; Printer printer = new Printer(arr, m); for(int i = 0;i\u0026lt;3;i++){ final int fi = i; new Thread(new Runnable() { @Override public void run() { printer.prient(fi); } }).start(); } } } class Printer { int[] arr; int threadCounts; int cursor; int currentThreadId; Printer(int[] arr, int count) { this.arr = arr; this.threadCounts = count; this.cursor = 0; this.currentThreadId = 0; } public void prient(int threadId) { synchronized (this) { while (true) { // 不是当前线程 阻塞 while (true) { if (cursor\u0026gt;=arr.length) { break; } if (threadId == currentThreadId) { break; } try { this.wait(); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } } if (cursor\u0026gt;=arr.length) { this.notifyAll(); break; } // 打印, 修改cursor, 和 current id System.out.println(\u0026#34;ThreadId: \u0026#34;+threadId+\u0026#39;\\t\u0026#39;+arr[cursor]); cursor++; currentThreadId = (currentThreadId + 1) % threadCounts; this.notifyAll(); } } } } ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-java-%E7%9B%B8%E5%85%B3%E9%A2%98%E7%9B%AE/","title":"LeetCode Java 相关题目"},{"content":"LeetCode 按位或最大的最小子数组长度 - 滑动窗口\r🟠 中等偏上 https://leetcode.cn/problems/smallest-subarrays-with-maximum-bitwise-or/description/\n记录每位1的最近位置\rclass Solution { public: vector\u0026lt;int\u0026gt; smallestSubarrays(vector\u0026lt;int\u0026gt;\u0026amp; nums) { int n = nums.size(); int right = n-1; int and_res = 0; int count[32]; vector\u0026lt;int\u0026gt; res(n,0); for(int i = n-1;i\u0026gt;=0;i--){ for(int j = 0;j\u0026lt;32;j++){ //及时更新1的位置，是最近的位置。 if(nums[i]\u0026amp;(1\u0026lt;\u0026lt;j)) count[j] = i; } int farst_right = i; for(int k = 0;k\u0026lt;32;k++){ // 找到最远的最近1 farst_right = max(farst_right,count[k]); } res[i] = farst_right-i+1; } return res; } }; 按结果分类\rhttps://www.bilibili.com/video/BV1MT411u7fW/?vd_source=a9a24992f7f570a16d5a331e8fed9f0d\n将 i 右侧按或的结果分为n段，i-1时，只用或上结果就行，合并相同的段。\nclass Solution { public: vector\u0026lt;int\u0026gt; smallestSubarrays(vector\u0026lt;int\u0026gt;\u0026amp; nums) { vector\u0026lt;pair\u0026lt;int,int\u0026gt;\u0026gt; or_res_index; int n = nums.size(); vector\u0026lt;int\u0026gt; res(n,0); for( int i = n-1;i\u0026gt;=0;i--){ or_res_index.emplace_back(0,i); for(int j = 0;j\u0026lt;or_res_index.size();j++){ or_res_index[j].first |=nums[i]; } int fast = 0,slow = 0; while(fast\u0026lt;or_res_index.size()){ if(or_res_index[slow].first!=or_res_index[fast].first){ slow++; or_res_index[slow] = or_res_index[fast]; } or_res_index[slow].second = or_res_index[fast].second; fast++; } or_res_index.resize(slow+1); res[i] = or_res_index[0].second-i+1; } return res; } }; ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-%E6%8C%89%E4%BD%8D%E6%88%96%E6%9C%80%E5%A4%A7%E7%9A%84%E6%9C%80%E5%B0%8F%E5%AD%90%E6%95%B0%E7%BB%84%E9%95%BF%E5%BA%A6-%E6%BB%91%E5%8A%A8%E7%AA%97%E5%8F%A3/","title":"LeetCode 按位或最大的最小子数组长度 - 滑动窗口"},{"content":"LeetCode 常用代码模板总结\r下标和循环次数的关系\r左为 left 循环次数为 count， 那么右侧最后一个为 left+count-1 。\n如果条件是 \u0026lt; 那么就是 \u0026lt;left+count。 右为 right 循环次数为 count， 那么左边最后一个为 right-count+1。 如果条件是 \u0026gt; 那么就是 \u0026gt;right-count。 数组中点\r长度为n， 求中点， n是奇数， (n-1)/2， 比如n=3， 刚好是1 n是偶数， (n-1)/2， 滑动到最后不同的\r2025年12月2日19:59:10\r使用while 先加加或者减减， 再使用while 判断， 比如right 位置， 如果直接使用while 进行循环， 那么第一次时 right = n-1， nums[right] != nums[right + 1] right+1 越界了， 因此要 先 right\u0026ndash;， 再处理循环， else {\r// 刚好等于, 收集结果\rans.add(List.of(nums[i], nums[left], nums[right]));\r// 平移指针\rleft++;\rwhile (true) {\rif (left \u0026gt;= right) {\rbreak;\r}\rif (nums[left] != nums[left - 1]) {\rbreak;\r}\rleft++;\r}\rright--;\rwhile (true) {\rif (left \u0026gt;= right) {\rbreak;\r}\rif (nums[right] != nums[right + 1]) {\rbreak;\r}\rright--;\r}\r} 222\u0026hellip;..24; 当前是 i 指向 2， 那么滑动到不是2的位置\n先滑倒最后一个2: while(nums[i]==nums[i+1]) i++， 最终停在不等的位置， 也就是最后一个2的位置，停在了最后一个i。 再后移一位 i++ 即可 [2-15三数之和](01 双指针/LeetCode 15 三数之和.md)\ni也可以和i-1 判断，这需要i 初始时向后移一位；i\u0026gt;0确保了不会漏掉一开始的一种情况，j\u0026gt;i+1同理。 j从 i+1开始，如果不加 j\u0026gt;i+1的条件：nums[j]==nums[j-1] j=i+1时，如果nums[i]==nums[i+1]，j=i+1就会被跳过。\nfor(int i = 0;i\u0026lt;n;i++){ if(i\u0026gt;0\u0026amp;\u0026amp;nums[i]==nums[i-1]) continue; for(int j = i+1;j\u0026lt;n;j++){ if(j\u0026gt;i+1\u0026amp;\u0026amp;nums[j]==nums[j-1]) continue; [8-31下一个排列](01 双指针/LeetCode 31 下一个排列.md) right初始化 while中两个条件。\nvoid nextPermutation(vector\u0026lt;int\u0026gt;\u0026amp; nums) { int n = nums.size(); int right = n-2; while(right\u0026gt;=0\u0026amp;\u0026amp;nums[right]\u0026gt;=nums[right+1]){ right--; } 快速找子数组\r方法一：前缀和+hash_table。计算前缀和后，设某一位置为前缀和为sum，检验 sum-target 的合法性即可。\n方法二：滑动窗口。只适用于正数。\n子数组不重叠\r使用left 来控制。[27-1477找两个和为目标值且不重复的子数组](02 滑动窗口/LeetCode 1477 找两个和为目标值且不重复的子数组.md)\nsize的返回值问题\runordered_set\u0026lt;int\u0026gt; hash_set;\rhash_set.size()-1; 这里的size返回的是无符号数，减一 滑动直到 和 满足条件\r要点：保证同一时间内 sum 中既有left 也有 right\nint left = 0; int sum = 0; for(int i = 0;i\u0026lt;nums.size();i++){ sum+=nums[i] // 同一时间，sum中既有left 也有 i while(sum\u0026gt;500){ sum-=nums[left] //2.减完后 left 后移，保证sum中有left left++; //1. 先写变量控制条件 } } 不重复\r可以使用 unordered_map value:count\nmap[value]++\rmap[value]--\rif(map[value]==0){\rmap.erase(value);\r} 动态最值问题-单调队列\rleetcode 官方 https://leetcode.cn/problems/sliding-window-maximum/solutions/543426/hua-dong-chuang-kou-zui-da-zhi-by-leetco-ki6m/\n[26-239滑动窗口最大值](02 滑动窗口/LeetCode 239 滑动窗口最大值.md)\n始终保持一个滑动窗口内的最大值和最小值。\n比如维护最大值：当一个新元素进入时，前面比他小的元素不可能成为包含当前元素的窗口中的最大值，直接弹出即可\n使用双端队列\n#include \u0026lt;iostream\u0026gt; #include \u0026lt;deque\u0026gt; #include \u0026lt;vector\u0026gt; using namespace std; vector\u0026lt;int\u0026gt; maxSlidingWindow(vector\u0026lt;int\u0026gt;\u0026amp; nums, int k) { deque\u0026lt;int\u0026gt; deque_max; // 存储的是索引 vector\u0026lt;int\u0026gt; result; for (int i = 0; i \u0026lt; nums.size(); ++i) { // 移除队尾小于当前元素的所有元素 // 递减排序，队头为最大值 while (!deque_max.empty() \u0026amp;\u0026amp; nums[deque_max.back()] \u0026lt; nums[i]) { deque_max.pop_back(); } deque_max.push_back(i); // 移除不在当前滑动窗口中的元素 if (!deque_max.empty() \u0026amp;\u0026amp; i-deque_max.front()+1 \u0026gt; k ) { // 这里直接移除首个元素，因为这是队列，最开始的一定在队头。 // 队列里面不一定包含窗口内的所有元素。 deque_max.pop_front(); } // 从第 k-1 个元素开始加入结果数组 if (i \u0026gt;= k - 1) { result.push_back(nums[deque_max.front()]); } } return result; } 动态top k 问题\r当数据范围较小时：可以使用计数排序， 直接构造一个数据范围的数组用来存储出现次数，再从小到大遍历并累加出现次数，直到累加和大于k。[6-2653滑动子数组的美丽值](02 滑动窗口/LeetCode 2653 滑动子数组的美丽值.md) 当数据会一直增加不删除时可以使用堆解决，比如求最大的k个元素，可以维护一个大小为k的小定堆（堆顶元素为这k个元素的最小值），后面插入的时候可以和堆顶元素比较，大于堆顶就先弹出后插入。 当数据既增加也删除时可以使用双堆： #include\u0026lt;iostream\u0026gt; #include\u0026lt;queue\u0026gt; #include\u0026lt;vector\u0026gt; using namespace std; //使用双堆求 len 窗口的第k小值。 class Solution{ public: int k; priority_queue\u0026lt;int\u0026gt; big_heap; //大顶堆 存前k小的元素，栈顶就是门槛。 priority_queue\u0026lt;int,vector\u0026lt;int\u0026gt;, greater\u0026lt;int\u0026gt;\u0026gt; small_heap; //小顶堆 存剩下的元素，栈顶就是剩下的最小的元素。 Solution(int val) : k(val) {} void add(int num){ if(big_heap.size()\u0026lt;k){ big_heap.push(num); }else{ //可能要查到小值里面，也可能插到大值里面。 if(num\u0026lt;big_heap.top()){ big_heap.push(num); small_heap.push(big_heap.top()); big_heap.pop(); }else{ small_heap.push(num); } } } void remove(int num){ vector\u0026lt;int\u0026gt; buffer; bool is_removed = false; if(num\u0026lt;=big_heap.top()){ while(big_heap.top()\u0026gt;=num\u0026amp;\u0026amp;!is_removed){ if(big_heap.top()==num) is_removed = true; buffer.push_back(big_heap.top()); big_heap.pop(); } //最后一个是要删除的 buffer.pop_back(); for(int val: buffer){ big_heap.push(val); } buffer.clear(); //删除后左边就不是k个了， 因此要从右边拿一个过来。 int tmp = small_heap.top(); small_heap.pop(); big_heap.push(tmp); }else{ while(small_heap.top()\u0026lt;=num\u0026amp;\u0026amp;!is_removed){ if(small_heap.top()==num) is_removed = true; buffer.push_back(small_heap.top()); small_heap.pop(); } //最后一个是要删除的 buffer.pop_back(); for(int val: buffer){ small_heap.push(val); } buffer.clear(); } } int get_small_k(){ return big_heap.top(); } }; int main() { Solution s(3); int len = 4; vector\u0026lt;int\u0026gt; stream = {4, 5, 8, 2,7,9,5,3}; for (int i = 0;i\u0026lt;len-1;i++) { s.add(stream[i]); cout \u0026lt;\u0026lt; \u0026#34;3rd smallest after adding \u0026#34; \u0026lt;\u0026lt; stream[i] \u0026lt;\u0026lt; \u0026#34;: \u0026#34; \u0026lt;\u0026lt; s.get_small_k() \u0026lt;\u0026lt; endl; } cout\u0026lt;\u0026lt;\u0026#34;begin----------\u0026#34;\u0026lt;\u0026lt;endl; for(int i = len-1;i\u0026lt;stream.size();i++){ s.add(stream[i]); cout \u0026lt;\u0026lt; \u0026#34;3rd smallest after adding \u0026#34; \u0026lt;\u0026lt; stream[i] \u0026lt;\u0026lt; \u0026#34;: \u0026#34; \u0026lt;\u0026lt; s.get_small_k() \u0026lt;\u0026lt; endl; s.remove(stream[i-len+1]); } return 0; } 为什么不用一个堆？（左边是小的，右边是大的） 插入时：可能查到最大堆里，也可能插到右边。没什么区别。 删除时：1. 删的元素在右边，没什么区别。 2. 删的元素在左边，需要右边找到最小元素加到左边。那么使用堆维护右边就可以直接取堆顶元素即可（当然插入右边时要进行调整：log 时间） 总结：1. 单堆插入右边时无操作，左边删除时要找到右边最小元素 O(n) 时间；2. 双堆插入右边时要调整 log 时间，左边删除时要找到右边最小元素，直接取栈顶即可。因此双堆更好尤其是这个窗口很大时，右边元素很多。 while\r写循环先写 逻辑， 最后再写边界条件， 边界控制是为了让程序不出错或按照预期执行 [2-209长度最小的子数组](02 滑动窗口/LeetCode 209 长度最小的子数组.md)\n试探性执行并检查结果。 这和 滑动直到不同的元素相似。\nwhile(向后判断，试探性的检查执行之后的){ 执行 } while (sum - nums[left] \u0026gt;= target) { //检查执行之后的结果再决定是否进入循环。 //剪掉左边还能达标，就左移 sum -= nums[left]; //执行，这里的就是判断中的条件。 left++; } 也可以不试探性检查，直接执行，最后的状态就会是不满足while条件的。\n也就是说 sum应该是小于target的，大于的情况是“违法状态”，\n//sum一开始是小数，所以while维护大数，至于答案的更新是在while里面还是 执行完while后是根据答案是小数还是大数。 while(sum\u0026gt;=target){ ans = min(ans,right-start+1); sum-=nums[right]; right--; } sum不可以无脑和题意反着来，要根据初始状态来定，[3-713乘积小于k的子数组](02 滑动窗口/LeetCode 713 乘积小于 K 的子数组.md) 应该是和初始状态反着来。 prod一开始是小的，while 是排除大的情况；上面 209题 的sum 一开始也是小的，while 也是排除大的情况。只不过乘积收集的结果是小的，所以在while循环之后收集结果，而209收集的是大的，因此209的ans 更新在while循环里。\n[11-1234替换子串得到平衡字符串](02 滑动窗口/LeetCode 1234 替换子串得到平衡字符串.md) 体会 while 的用法。\nwhile 父子关系\rwhile (t != null) { parent = t; // 保存当前节点作为父节点 cmp = k.compareTo(t.key); // 比较 key 的大小 if (cmp \u0026lt; 0) { t = t.left; // 如果 key 小于当前节点，移动到左子树 } else if (cmp \u0026gt; 0) { t = t.right; // 如果 key 大于当前节点，移动到右子树 } else { V oldValue = t.value; // 找到相同的 key if (replaceOld || oldValue == null) { t.value = value; // 更新节点值 } return oldValue; // 返回旧值 } } father while(判断 child){ father = child; 改变child } 循环结束 使用father while遍历时一个位置可能多次操作\r[9-75颜色分类](01 双指针/LeetCode 75 颜色分类.md)\n把i++放在合法的情况中，其他的情况各自处理。尽量使用if else 而非 多个if（可能会互相影响）。\nclass Solution { public: void sortColors(vector\u0026lt;int\u0026gt;\u0026amp; nums) { int n = nums.size(); int p0 = 0; int p2 = n - 1; int i = 0; while(i\u0026lt;=p2){ if(nums[i]==0){ //交换完nums_i 一定是 1 或 0 ，因此合法了 swap(nums[i],nums[p0]); p0++; i++; }else if(nums[i]==2){ swap(nums[i],nums[p2]); p2--; }else{ //合法情况 i++; } } } }; 递归和while\rint child = heap.size()-1; //递归输入。 while(true){ if(child\u0026lt;=0) break; //break 为跳出递归 int parent = (child-1)/2; if(heap[child]\u0026gt;heap[parent]) break; // 递归终止条件。 swap(heap[child],heap[parent]); // 本层要干的事情。 child = parent; // 在while的最底下更新递归的输入。 } down 函数，递归函数输入为：开始的index。当比下层都大时递归终止；本层交换父子节点；最后在while 的最底下更新递归的输入 index。\nvoid down(int index) { while (true) { int left_child = index * 2 + 1; int right_child = left_child + 1; int real_index = index; if (left_child \u0026lt; heap.size() \u0026amp;\u0026amp; heap[left_child] \u0026lt; heap[real_index]) { real_index = left_child; } if (right_child \u0026lt; heap.size() \u0026amp;\u0026amp; heap[right_child] \u0026lt; heap[real_index]) { real_index = right_child; } if (index == real_index) { break; } swap(heap[index], heap[real_index]); index = real_index; } } 如果递归函数内部逻辑复杂， 我们可以增加入参。 class Heap{ public static void downAdjust(int[] a, int startIndex, int endIndex){ int parentIndex = startIndex; int childIndex = parentIndex*2+1; // 如果递归函数只有一个入参: parentIndex 会导致下面逻辑复杂, 这里使用两个入参 while(true){ if (childIndex\u0026gt;endIndex){ break; } if (childIndex+1\u0026lt;=endIndex\u0026amp;\u0026amp;a[childIndex]\u0026lt;a[childIndex+1]){ childIndex++; } if (a[parentIndex]\u0026gt;=a[childIndex]){ break; } int tmp = a[parentIndex]; a[parentIndex] = a[childIndex]; a[childIndex] = tmp; parentIndex = childIndex; childIndex = parentIndex*2+1; } } public static int[] HeapSort(int[] a){ int n = a.length; // 构建大顶堆, 从最后一个非叶节点向下调整 int lastNotLeafNodeIndex = n/2-1; for (int i = lastNotLeafNodeIndex; i \u0026gt;=0 ; i--) { downAdjust(a,i,n-1); } // 交换堆顶(a[0]) 和最后一个元素, 重新向下调整 for (int i = n-1; i \u0026gt;=0; i--) { // i: 数组要确定的下标, 从n-1 开始确定 int tmp = a[0]; a[0] = a[i]; a[i] = tmp; downAdjust(a,0,i-1); } return a; } } [21-443压缩字符串](01 双指针/LeetCode 443 压缩字符串.md) 递归爆改while\n设置-1\r[24-1358包含所有三种字符的子串数目](02 滑动窗口/LeetCode 1358 包含所有三种字符的子串数目.md) 动规解法中，-1的设置巧妙地省去了很多判断。\n在保持 res = left-(-1 ) 不变的情况下，迫使一开始也满足条件，只能初始化为 -1。\n链表倒数第k\r使用 count 作为步数（两个节点之间的跳跃）；\n删除倒数第n个节点要找到倒数第n+1 节点：count\u0026gt;=n+1;\nfast slow 都指向第一个节点，找倒数第k个节点 count\u0026gt;=k\n途中slow 应该移动时 count时fs 的跳跃次数为2。 int count = 0; while(fast-\u0026gt;next!=nullptr){ fast = fast-\u0026gt;next,count++; if(count\u0026gt;=n+1){ slow = slow-\u0026gt;next; } } 链表\r如果选第一个时不能直接确定， 直接加上 fake_head 必要时加 fake_head while（）条件 ：cur!=nullptr cur-\u0026gt;next!=nullptr 取决于最后一个节点是否要进入循环执行。\n2026-01-21， 很多情况不是算节点个数，而是步数(链表指针数) 链表中间节点\r中间和中间偏后 class Solution { public ListNode middleNode(ListNode head) { ListNode slow = head, fast = head; while (fast != null \u0026amp;\u0026amp; fast.next != null) { slow = slow.next; fast = fast.next.next; } return slow; } } 中间和中间偏前， fast初始化就向前一步 ListNode slow = head; ListNode fast = head.next; while (fast!=null\u0026amp;\u0026amp;fast.next!=null) { slow = slow.next; fast = fast.next.next; } 对齐\r自己加 aaa bbb ccc 截取其中的字符串，因为数据不齐，因此可以选择在前面补。 s = \u0026#39; \u0026#39;+s; //在前面加一个空格。 n = s.size(); int i = n-1,j = n-1; while(i\u0026gt;=0){ if(s[i]!=\u0026#39; \u0026#39;\u0026amp;\u0026amp;s[i+1]==\u0026#39; \u0026#39;) j = i; if(s[i]!=\u0026#39; \u0026#39;\u0026amp;\u0026amp;s[i-1]==\u0026#39; \u0026#39;) res.push_back(s.substr(i,j-i+1)); i--; } 设置初始值：通过一个装有abc的数组要得到字符串 a b c 设置初始这为a 遍历加空格和下一个。 string res_s = res[0]; for(int i = 1;i\u0026lt;res.size();i++){ res_s+=\u0026#39; \u0026#39;; res_s+=res[i]; } 两个数据对比，但两个数据长度不一；假返回或设置初始值 [17-165比较版本号](01 双指针/LeetCode 165 比较版本号.md) 假返回的思路通过设置初始值实现。 找最小值， 可能为null\r堆的向下调整 先设置默认值， 然后逐步更新 private void down(int index){ int n = heap.size(); while (true) { if (index\u0026gt;=n) { break; } // 判断要不要交换, 找到要交换的下标 int leftChildIndex = index*2+1; int rightChildIndex = index*2+2; int realSwapIndex = index; if (leftChildIndex\u0026lt;n) { realSwapIndex = heap.get(realSwapIndex)\u0026lt;=heap.get(leftChildIndex)?realSwapIndex:leftChildIndex; } if (rightChildIndex\u0026lt;n) { realSwapIndex = heap.get(realSwapIndex)\u0026lt;=heap.get(rightChildIndex)?realSwapIndex:rightChildIndex; } // swap swap(index, realSwapIndex); // 更新 index if (realSwapIndex==index) { break; }else{ index = realSwapIndex; } } } 前缀和\r记得加0\nmap.put(0,1) 递归转while\r二叉树中序遍历\rclass Solution { public List\u0026lt;Integer\u0026gt; inorderTraversal(TreeNode root) { List\u0026lt;Integer\u0026gt; list = new ArrayList\u0026lt;\u0026gt;(); dfs(root, list); return list; } private void dfs(TreeNode root,List list){ if (root==null) { return; } dfs(root.left, list); list.add(root.val); dfs(root.right, list); } } 转为递归 class Solution { public List\u0026lt;Integer\u0026gt; inorderTraversal(TreeNode root) { // 迭代算法, 使用栈模拟递归调用 List\u0026lt;Integer\u0026gt; list= new ArrayList\u0026lt;\u0026gt;(); Deque\u0026lt;TreeNode\u0026gt; deque = new LinkedList\u0026lt;\u0026gt;(); TreeNode cur = root; while (true) { if (cur==null\u0026amp;\u0026amp;deque.isEmpty()) { break; } // 调用 root.left, 入栈 if (cur!=null) { deque.push(cur); cur = cur.left; }else{ cur = deque.pop(); list.add(cur.val); cur = cur.right; } } return list; } } 本质是修改传参， 这里是修改cur， 关于什么时候入栈， 什么时候出栈， 当修改传参之前要考虑是否要入栈出栈 A+B 变为 B+A\rA+B -\u0026gt; rev(A)+rev(B) - \u0026gt;rev[rev(A)+rev(B)] = B+A 避开第一次\rpublic class Solution { public boolean hasCycle(ListNode head) { // slow 和fast是当前位置, ListNode slow = head; ListNode fast = head; while (slow!=null\u0026amp;\u0026amp;fast!=null\u0026amp;\u0026amp;fast.next!=null) { slow = slow.next; fast = fast.next.next; // 我们检查当前位置是否相遇, 不在while内第一条检查,在while最后一条前检查 if (slow==fast) { return true; } } return false; } } 复杂情况- 先设置为默认值， 再修改\r合并链表 while (a != null || b != null) { ListNode temp = a; if (b != null) { if (temp == null) { temp = b; } else { temp = a.val \u0026lt; b.val ? a : b; } } if (temp == a) { a = a.next; } else { b = b.next; } cur.next = temp; cur = cur.next; } 合并， 先设置为默认值， 再赋值 // 合并 n / (2 * step)+1 次, 剩下的可能不到 2 * step, 但是可能大于一个step, 因此需要1次, for (int i = 0; i \u0026lt; n / (2 * step)+1; i++) { ListNode first = cur.next; cur.next = null; ListNode secend = null; ListNode other = null; ListNode firstEnd = tryStep(first, step - 1); if (firstEnd != null \u0026amp;\u0026amp; firstEnd.next != null) { secend = firstEnd.next; firstEnd.next = null; } ListNode secendEnd = tryStep(secend, step - 1); if (secendEnd != null) { other = secendEnd.next; secendEnd.next = null; } ListNode[] arr = merge(first, secend); cur.next = arr[0]; arr[1].next = other; cur = arr[1]; } ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-%E5%B8%B8%E7%94%A8%E4%BB%A3%E7%A0%81%E6%A8%A1%E6%9D%BF%E6%80%BB%E7%BB%93/","title":"LeetCode 常用代码模板总结"},{"content":"LeetCode 二叉树题型总结\r层序遍历\r核心思路: 维护一个队列，保持队列中是本层的节点就行 队列中维护非null 节点 class Solution { public List\u0026lt;List\u0026lt;Integer\u0026gt;\u0026gt; levelOrder(TreeNode root) { Queue\u0026lt;TreeNode\u0026gt; queue = new LinkedList\u0026lt;\u0026gt;(); List\u0026lt;List\u0026lt;Integer\u0026gt;\u0026gt; ans = new ArrayList\u0026lt;\u0026gt;(); if (root==null) { return ans; } queue.offer(root); while (!queue.isEmpty()) { int currentSize = queue.size(); List\u0026lt;Integer\u0026gt; list = new ArrayList\u0026lt;\u0026gt;(); for(int i = 0;i\u0026lt;currentSize;i++){ TreeNode temp = queue.poll(); list.add(temp.val); if (temp.left!=null) { queue.offer(temp.left); } if (temp.right!=null) { queue.offer(temp.right); } } ans.add(list); } return ans; } } 层序建树\rqueue 中存储的是非null 元素, /** * 层序建树 - 使用数组（null表示空节点） * @param arr 层序遍历的数组，null表示空节点 * @return 根节点 */ public static TreeNode buildTree(Integer[] arr) { if (arr == null || arr.length == 0 || arr[0] == null) { return null; } // 创建根节点 TreeNode root = new TreeNode(arr[0]); Queue\u0026lt;TreeNode\u0026gt; queue = new LinkedList\u0026lt;\u0026gt;(); queue.offer(root); int i = 1; while (!queue.isEmpty() \u0026amp;\u0026amp; i \u0026lt; arr.length) { TreeNode current = queue.poll(); // 创建左子节点 if (i \u0026lt; arr.length \u0026amp;\u0026amp; arr[i] != null) { current.left = new TreeNode(arr[i]); queue.offer(current.left); } i++; // 创建右子节点 if (i \u0026lt; arr.length \u0026amp;\u0026amp; arr[i] != null) { current.right = new TreeNode(arr[i]); queue.offer(current.right); } i++; } return root; } ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-%E4%BA%8C%E5%8F%89%E6%A0%91%E9%A2%98%E5%9E%8B%E6%80%BB%E7%BB%93/","title":"LeetCode 二叉树题型总结"},{"content":"LeetCode 解题思路总结\r回溯\r从答案角度：往 path 中添加元素。 从本层输入角度：做“选 / 不选”的分支。 动态规划\r第一类：dp 最终位置就是问题答案。 递归定义通常就是原问题本身。 第二类：dp 最终位置不是直接答案，需要再做一次筛选（最大值、最小值等）。 递归定义通常是“某一类状态”而非原问题本身。 先穷举状态并分类，再加入最优属性；若“当前类最优值”依赖“其他类最优值”，该分类通常有效。 常见思路\r先给可能答案分类，再分析各类之间关系，是非常实用的方法。 最大子数组和：按起点或终点分类都能得到递推关系。 乘积小于 k 的子数组：按右端点分类更自然。 递归的本质是抽取“本问题和子问题的同构模式”；差异部分放到参数或全局维护变量里。 如何判断题型\r子数组 / 子串长度、替换次数、删除次数：优先考虑滑动窗口。 路径、组合、子集、分割：优先考虑回溯。 子数组、子序列的最值或计数：优先考虑动态规划。 零散规律\r已知数据规模且要求第 K 大 / 第 K 小时，可优先考虑快速选择。 前缀和适合处理任意起点、任意长度的连续子数组和。 子数组 DP 中，“以 i 结尾”的状态至少包含 nums[i]。 子串统计中，长串出现次数不会超过其短前缀子串出现次数。 ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-%E8%A7%A3%E9%A2%98%E6%80%9D%E8%B7%AF%E6%80%BB%E7%BB%93/","title":"LeetCode 解题思路总结"},{"content":"LeetCode 零散笔记\rLRU\r核心结构：哈希表 + 双向链表。\n哈希表负责 O(1) 查找节点。 双向链表负责维护访问顺序。 最近访问的节点移动到链表头部。 超出容量时删除链表尾部节点。 两数之和 / 和为 K 的子数组\r这类题通常优先考虑哈希表。\n思路区别：\n两数之和：记录已经出现过的数，判断 target - nums[i] 是否存在。 和为 K 的子数组：使用前缀和，记录每个前缀和出现次数。 前缀和\r前缀和题目要特别注意初始化：\nmap.put(0, 1); 含义：在还没有遍历任何元素时，前缀和为 0 出现过一次。\n如果不加这一步，从数组开头开始、和刚好为 k 的子数组会被漏掉。\n路径总和\r题目链接：路径总和 III\n常见思路：\n双重递归：外层枚举每个节点作为起点，内层向下统计路径和。 前缀和：记录从根节点到当前节点的路径和，查询是否存在 currentSum - targetSum。 如果先追求直观理解，可以先掌握双重递归；如果追求更高效率，再使用前缀和优化。\n","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-%E9%9B%B6%E6%95%A3%E9%A2%98%E5%9E%8B%E7%AC%94%E8%AE%B0/","title":"LeetCode 零散题型笔记"},{"content":"LeetCode 排序算法题型总结\r快排\r第一次拿起x = nums[left]，从右侧找一个比x小的数放到 lfet位置\r这时右侧的right空了，再从左侧找个数放到右侧，\r以此来推\r直到left\u0026gt;=right，我们可以保证 left左侧都是严格小于x的，这时将x放到left的位置（或者这样理解，因为循环最后是nums[j] = nums[i]， 所以最后跳出循环后一定是左侧left有空位，所以放到left） class QuickSort { public void quickSort(int[] nums) { if (nums == null || nums.length \u0026lt;= 1) return; sort(nums, 0, nums.length - 1); } private void sort(int[] nums, int l, int r) { if (l \u0026gt;= r) return; // 递归结束条件 int pivotIndex = partition(nums, l, r); // 对左右两边都进行递归排序 sort(nums, l, pivotIndex - 1); sort(nums, pivotIndex + 1, r); } private int partition(int[] nums, int l, int r) { int pivot = nums[l]; // 选择最左元素作为pivot int i = l, j = r; while (i \u0026lt; j) { while (i \u0026lt; j \u0026amp;\u0026amp; nums[j] \u0026gt;= pivot) j--; // 从右找小于pivot的 nums[i] = nums[j]; // 覆盖到左边 while (i \u0026lt; j \u0026amp;\u0026amp; nums[i] \u0026lt;= pivot) i++; // 从左找大于pivot的 nums[j] = nums[i]; // 覆盖到右边 } nums[i] = pivot; // 将pivot放到最终位置 return i; } } 优化-三路划分\r注意 swap(nums，i++，lt++); 因为 交换前 nums[it]=p，nums[i]\u0026lt;p，交换后 nums[i]=p这是已知的，所以i++，同时这也保证了 i\u0026gt;=it class Solution { private Random r = new Random(); public int findKthLargest(int[] nums, int k) { int n = nums.length; int target = n-1-k+1; return helper(nums, 0, n-1, target); } private int helper(int[] nums, int left, int right ,int target){ int x = left+ r.nextInt(right-left+1); swap(nums, x, left); int pivot = nums[left]; // [left,lt-1]\u0026lt;p, [lt,i]=p , [gt+1,right]\u0026gt;p int lt = left, gt = right , i = left; while (i\u0026lt;=gt) { // 保证nums[gt+1]\u0026gt;p, 但是gt这个位置还要判断 if (nums[i]\u0026lt;pivot) { swap(nums, i++, lt++); }else if (nums[i]\u0026gt;pivot) { swap(nums, i, gt--); }else{ i++; } } if (target\u0026lt;=lt-1) { return helper(nums, left, lt-1, target); }else if (target\u0026gt;=gt+1) { return helper(nums, gt+1, right, target); }else{ return nums[lt]; } } private void swap(int[] nums, int a,int b){ int temp = nums[a]; nums[a] = nums[b]; nums[b] = temp; } } ","date":"2026-04-11T00:00:00Z","permalink":"/p/leetcode-%E6%8E%92%E5%BA%8F%E7%AE%97%E6%B3%95%E9%A2%98%E5%9E%8B%E6%80%BB%E7%BB%93/","title":"LeetCode 排序算法题型总结"},{"content":"Linux 常用命令\r查看系统信息\r常用的系统信息命令可以帮助快速辨别硬件架构和发行版。\n查看 CPU 架构：\nuname -m 常见输出示例：x86_64。\n查看系统发行版信息：\ncat /etc/os-release 后台任务\rjobs / bg / fg\r这组命令适合在同一个终端中暂存和恢复暂停的前台任务。\n常用命令：\njobs：查看当前后台任务。 bg %1：让编号为 1 的任务在后台继续运行。 fg %1：把编号为 1 的任务恢复到前台。 screen\rscreen 可以在独立会话中运行命令，终端断开后仍然可以恢复查看。\n常用操作：\nscreen：进入一个新的 screen 会话。 Ctrl + A，再按 D：从当前 screen 会话分离。 screen -r：恢复之前的 screen 会话。 tmux\rtmux 的作用与 screen 类似，常被用来管理长期运行的会话。\n后台运行命令\r将任务送入后台运行并丢弃输出是常见的需求，下面展示两种写法。\n./someserver-linux-amd64 server \u0026amp;\u0026gt;/dev/null \u0026amp; 说明：\n\u0026amp;\u0026gt;：重定向标准输出和标准错误。 /dev/null：丢弃输出内容。 最后的 \u0026amp;：让命令在后台执行。 注意：只使用 \u0026amp; 时，关闭终端后任务通常也会结束。\n如果希望关闭终端后任务继续运行，可以使用 nohup：\nnohup ./someserver-linux-amd64 server \u0026amp;\u0026gt;/dev/null \u0026amp; 进程信息\r以下命令用于排查运行中的进程和其资源占用。\n列出进程：\nps aux 按进程名查找\rps aux | grep nginx 也可以使用：\npgrep nginx pgrep 只返回进程号。\n查看 CPU 占用\rtop 显示实时的 CPU 和内存占用情况，默认按 %CPU 排序。\ntop 查看占用文件的进程\rlsof 合 grep 可以定位占用某个文件或套接字的进程。\nlsof | grep nginx 查看监听端口\rss -plnt 是查看监听端口及其进程的常用组合。\nss -plnt 参数说明：\np：显示进程 ID 和进程名。 l：只显示监听中的 socket。 n：以数字形式显示 IP 和端口。 t：只显示 TCP 连接；如果要查看 UDP，可以改为 u。 终止进程\r使用 PID 终止某个进程：\nkill 1234 终止 PID 为 1234 的进程。\nkillall nginx pkill nginx killall 和 pkill 都可以按进程名终止进程。\n解压文件\r不同压缩格式对应不同解压命令，按需执行即可。\n.tar\rtar -xvf filename.tar 参数说明：\n-x：解压文件。 -v：显示解压过程中的文件信息，可选。 -f：指定文件名。 -C：解压到指定目录。 解压到指定目录：\ntar -xvf filename.tar -C /home/user/desktop .tar.gz / .tgz\rtar -xzvf filename.tar.gz tar -xzvf filename.tgz -z 表示文件使用 gzip 压缩。\n.zip\runzip filename.zip .zip 归档可以使用 unzip 直接展开。\n.rar\runrar x filename.rar .rar 通常使用 unrar 解压。\n.7z\r7z x filename.7z .7z 格式则使用 7z 命令。\n删除和新建\r删除\rrm file.txt 删除单个文件。\nrm -r mydir 递归删除文件夹。\nrm -rf mydir 强制递归删除文件夹，使用前务必确认路径，避免误删。\n新建\rtouch file.txt 新建空文件。\necho \u0026#34;hello world!\u0026#34; \u0026gt; file.txt 写入文本并创建文件；如果文件已存在，会覆盖原内容。\nmkdir mydir 新建文件夹。\nsystemd 服务\r服务文件通常放在：\n/etc/systemd/system/myservice.service 示例：\n[Unit] Description=My Script Service After=network.target [Service] ExecStart=/usr/local/bin/myscript.sh Restart=always User=myuser Group=mygroup [Install] WantedBy=multi-user.target 关键配置：\nExecStart：服务启动时执行的命令。 Restart：服务异常退出后的重启策略。 User / Group：服务运行时使用的用户和用户组。 常用命令：\nsudo systemctl daemon-reload sudo systemctl start my_service sudo systemctl enable my_service sudo systemctl stop my_service sudo systemctl disable my_service sudo rm /etc/systemd/system/my_service.service sudo systemctl daemon-reload 说明：\n修改或新增 service 文件后，需要执行 systemctl daemon-reload。 enable 表示开机自启。 disable 表示取消开机自启。 ","date":"2026-04-11T00:00:00Z","permalink":"/p/linux-%E5%B8%B8%E7%94%A8%E5%91%BD%E4%BB%A4/","title":"Linux 常用命令"},{"content":"Linux 目录结构\rLinux 的文件系统以 / 为根节点，下面是常见的目录及其用途。\n/bin 或 /usr/bin：存放系统级命令。/bin 是系统启动时即可访问的最小运行环境，/usr/bin 包含用户安装或日常使用的软件。 /boot：启动加载程序和内核相关文件。 /dev：设备是 Linux 中的文件，所有外设都以文件形式呈现。 /etc：系统配置文件所在目录，例如 ssh/sshd_config 是 SSH 的配置。 /home：普通用户的家目录，类似 Windows 的用户目录。 /initrd.img：启动时的初始内存盘映像，通常是 /boot 的软链接。 /lib：动态库文件，等价于 Windows 的 DLL，程序的依赖都在这里。 /lost+found：强制关机后系统用来存放丢失文件的目录。 /media：系统自动挂载光盘或 U 盘等介质的目录。 /mnt：临时挂载点，手动挂载光盘、U 盘等时常用此目录。 /opt：第三方软件或附加工具的安装目录，不常用。 /proc：虚拟文件系统，内核和进程状态可在此读取。 /root：root 用户的家目录。 /run：系统运行时生成的临时文件。 /sbin：供超级用户使用的系统维护命令，例如启动、修复或挂载文件系统。 /srv：某些服务运行时需要的数据和脚本可以放在这里。 /sys：表示内核、设备和文件系统的状态信息。 /tmp：临时文件目录，会定期清理。 /usr：共享资源目录，包含用户空间程序、库和本地安装内容： /usr/bin：比 /bin 更大的用户可执行程序目录，包含非系统必须的软件。 /usr/sbin：用于系统管理的命令，但不是启动必须。 /usr/lib：程序依赖库。 /usr/local：本地安装的软件，通常由用户手动构建或第三方脚本安装。 /usr/local/bin 也在 PATH 中，常放可执行文件。 /usr/include：C/C++ 头文件。 /var：可变数据目录，如日志、邮件、缓存等不断更新的文件。 ","date":"2026-04-11T00:00:00Z","permalink":"/p/linux-%E7%9B%AE%E5%BD%95%E7%BB%93%E6%9E%84/","title":"Linux 目录结构"},{"content":"用户\r查看所有用户\r查看系统中注册的用户可以通过以下命令：\ncat /etc/passwd 每一行的字段含义为：\n用户名:密码:用户ID:组ID:注释:主目录:登录 shell root：用户名。 x：密码占位符（实际密码存储在 /etc/shadow）。 0：用户 ID (UID)，0 是超级用户。 0：组 ID (GID)，表示这个用户所属的初始组。 root：注释，通常用于描述该账户。 /root：主目录。 /bin/bash：默认登录 shell。 添加新用户\r最简单的方式：\nadduser username 可以指定更多信息，例如：\nsudo adduser --uid 1001 --gid 100 --groups sudo,adm --home /home/mycustomhome --shell /bin/bash --comment \u0026#34;My Custom User\u0026#34; myuser 选项说明：\n--gid：指定初始组。 --groups：让该用户加入多个组。 --home：自定义主目录。 --shell：登录 shell，可以是 /bin/bash、/usr/sbin/nologin 等。 --comment：用户描述。 创建 newuser 时，系统会自动为其新建同名用户组，cat /etc/group 可以查看所有组。\n示例输出：\nroot@debian:~# adduser zzz\rAdding user `zzz\u0026#39; ...\rAdding new group `zzz\u0026#39; (1002) ...\rAdding new user `zzz\u0026#39; (1002) with group `zzz (1002)\u0026#39; ...\rCreating home directory `/home/zzz\u0026#39; ...\rCopying files from `/etc/skel\u0026#39; ...\rNew password:\rRetype new password:\rpasswd: password updated successfully\rChanging the user information for zzz\rEnter the new value, or press ENTER for the default\rFull Name []:\rRoom Number []:\rWork Phone []:\rHome Phone []:\rOther []:\rIs the information correct? [Y/n]\rAdding new user `zzz\u0026#39; to supplemental / extra groups `users\u0026#39; ...\rAdding user `zzz\u0026#39; to group `users\u0026#39; ... 删除用户\rdeluser username 默认情况下，如果该组没有其它用户，deluser 会一并删除该组。\n示例：\nroot@debian:~# deluser zzz\rRemoving crontab ...\rRemoving user `zzz\u0026#39; ...\rDone.\rroot@debian:~# 组\r查看组\r查看当前用户所属于的组：\ngroups 示例：\nroot@debian:~# groups\rroot\rroot@debian:~# ^C 查看系统所有组：\ncat /etc/group 输出示例（省略部分）：\nroot:x:0:\rdaemon:x:1:\rbin:x:2:\rsys:x:3:\radm:x:4:\rtty:x:5:\rdisk:x:6:\rlp:x:7:\rmail:x:8:\rnews:x:9:\ruucp:x:10:\rman:x:12:\r...\rDebian-gdm:x:121:\rhzl:x:1000:\rgnome-initial-setup:x:995:\rhhh:x:1001: 添加组\raddgroup groupname 示例：\nroot@debian:~# addgroup hans_group\rAdding group `hans_group\u0026#39; (GID 1002) ...\rDone.\rroot@debian:~# 添加用户到组\rusermod -aG groupname username 该命令在不移除原有组的前提下，把 username 加入指定组。\n删除组\rdelgroup groupname 示例：\nroot@debian:~# delgroup hhh\rRemoving group `hhh\u0026#39; ...\rgroupdel: cannot remove the primary group of user \u0026#39;hhh\u0026#39;\rdelgroup: `/sbin/groupdel hhh\u0026#39; returned error code 8. Exiting.\rroot@debian:~# delgroup hans_group\rRemoving group `hans_group\u0026#39; ...\rDone.\rroot@debian:~# 权限\r权限类型\rLinux 文件和目录的权限分为三种类型：\n读取（r）：允许查看文件内容或列出目录。 写入（w）：允许修改文件或在目录中添加/删除条目。 执行（x）：允许执行文件或访问目录。 权限分配\r所有者（Owner）：通常是文件创建者。 用户组（Group）：与所有者同组的用户集合。 其他用户（Others）：系统中的其他人。 权限表示\r-rwxr-xr-- 第一个字符表示文件类型（- 表示文件，d 表示目录）。 后续九个字符分为三组三个：所有者、组、其他用户。 rwx：所有者的权限（读、写、执行）。 r-x：组的权限（读、无写、执行）。 r--：其他用户的权限（只读）。 查看文件权限\rls -l 示例：\nhzl@debian:~/Desktop$ ls -l\rtotal 36\rdrwxr-xr-x 2 hzl hzl 4096 May 6 07:03 fenjiduan\r-rwxr-xr-x 1 hzl hzl 16552 May 6 06:36 hello_world\r-rw-rw-rw- 1 root root 84 May 6 06:33 hello_world.cpp\rdrwxr-xr-x 2 hzl hzl 4096 May 6 07:56 maketest\rdrwxr-xr-x 2 hzl hzl 4096 May 6 06:45 multifile\rhzl@debian:~/Desktop$ 修改文件权限\rchmod u+x filename chmod g-w filename # 从组去除写权限 chmod o+x filename 使用数字方式设置权限：\nrwx 111 7\rr-x 101 5\rchmod 755 filename # 所有者读写执行，组和其他用户读执行 改变所有者和组\rchown newowner filename chown :newgroup filename ","date":"2026-04-11T00:00:00Z","permalink":"/p/linux-%E7%94%A8%E6%88%B7%E4%B8%8E%E7%94%A8%E6%88%B7%E7%BB%84%E7%AE%A1%E7%90%86/","title":"Linux 用户与用户组管理"},{"content":"Maven 项目构建工具\r安装\rmvn -v 修改仓库位置 安装目录/conf/settings.xml , 没有 引号. \u0026lt;localRepository\u0026gt;C:\\Users\\20881\\Documents\\Maven\\apache-maven-3.9.9-bin\\apache-maven-3.9.9\\mav_repo\u0026lt;/localRepository\u0026gt; idea 配置 maven\r全局\r导入maven项目\r分模块设计和开发\r拆分后的模块\n在项目文件中引入即可\n继承和聚合\r有些依赖所有的都要引用, 这些可以添加到parent中, 子模块继承即可 实现\rtalis-parent \u0026lt;packaging\u0026gt;pom\u0026lt;/packaging\u0026gt;\r\u0026lt;parent\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-parent\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.4.1\u0026lt;/version\u0026gt; \u0026lt;relativePath/\u0026gt; \u0026lt;!-- lookup parent from repository --\u0026gt; \u0026lt;/parent\u0026gt; talis-pojo 继承之后, groupid 就失效了\n总结\r版本控制\rdependencyManagement\r父工程中的 dependencyManagement 中只是管理版本, 子工程中不会直接依赖, 需要使用 dependency 标签引用, 不用标 版本而已 父工程中的 dependency 标签, 子工程中会直接引用过来. properties\r聚合\r不聚合: 构建目标项目文件需要 先安装他的依赖, 要一个一个手动安装 总结\r私服\r","date":"2026-04-11T00:00:00Z","permalink":"/p/maven-%E9%A1%B9%E7%9B%AE%E6%9E%84%E5%BB%BA%E5%B7%A5%E5%85%B7/","title":"Maven 项目构建工具"},{"content":"MyBatis 基础与使用\r1 创建\r勾选依赖 : MyBaits Framework , MySql Driver; resource/application.properties ; 添加数据库信息 1.1 代码提示\r选中 \u0026lsquo;select * from user\u0026rsquo; 右键-\u0026gt;显示上下文操作-\u0026gt;语言注入设置-\u0026gt;选择 Mysql即可, 写代码有提示; 库名和表名一样时表名要全, user.user 只写一个user不行 在 idea 中连接数据库后, 表名和字段名也会有提示; 1.2 连接池\r\u0026lt;!-- https://mvnrepository.com/artifact/com.alibaba/druid --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.alibaba\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;druid\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.2.24\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; spring.application.name=SpringBootMyBatis #驱动类名称 spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver #数据库连接的url spring.datasource.url=jdbc:mysql://localhost:3306/user #连接数据库的用户名 spring.datasource.username=root #连接数据库的密码 spring.datasource.password=1234 spring.datasource.type = com.alibaba.druid.pool.DruidDataSource 2 lombok\r框架: 编译时自动生成 getter setter toString\u0026hellip; 3 预编译\r3.1 sql注入\r@Delete(\u0026#34;delete from emp where id = #{id}\u0026#34;) // 这个是站位, 不会sql注入问题 int delete(Integer id);//返回操作影响的数据 @Delete(\u0026#34;delete from emp where id = ${id}\u0026#34;) // 这个是拼接, 有sql注入问题 int delete(Integer id);//返回操作影响的数据 4 基础操作\r4.1 命名\r数据库中 可以使用下划线, 实体类中使用驼峰命名法;\n4.2 删除\rMapper 标记 接口 @Autowired private EmpMapper empMapper; @Test public void testDelete(){ System.out.println(empMapper.delete(16)); } 4.3 插入\r接口类前面要Mapper注解 @Insert(\u0026#34;insert into emp(username, name, gender, image, job, entrydate, dept_id, create_time, update_time) \u0026#34;+ \u0026#34;values (#{username},#{name},#{gender},#{image},#{job},#{entrydate},#{deptId},#{createTime},#{updateTime})\u0026#34;) void insert(Emp emp); public void testInsert(){ Emp emp = new Emp(); emp.setUsername(\u0026#34;Tom1\u0026#34;); emp.setName(\u0026#34;Tom\u0026#34;); emp.setGender((short)1); emp.setImage(\u0026#34;1.img\u0026#34;); emp.setJob((short)2); emp.setEntrydate(LocalDate.of(2006,3,4)); emp.setDeptId(1); emp.setCreateTime(LocalDateTime.now()); emp.setUpdateTime(LocalDateTime.now()); empMapper.insert(emp); } 4.3.1 插入返回主键\rdishMapper.insert(dish); // xml文件中设置属性回显, useGeneratedKeys=\u0026#34;true\u0026#34; keyProperty=\u0026#34;id\u0026#34; 返回的值赋值为传入insert的参数dish的id, 再获取属性 Long dishId = dish.getId(); 4.4 修改\r@Update(\u0026#34;update emp set username = #{username},name = #{name},gender = #{gender},image = #{image}, job = #{job}, entrydate = #{entrydate},dept_id = #{deptId}, update_time = #{updateTime} where id = #{id};\u0026#34;) void update(Emp emp); @Test public void testUpdate(){ Emp emp = new Emp(); emp.setId(18); emp.setUsername(\u0026#34;update_test\u0026#34;); emp.setName(\u0026#34;Tom\u0026#34;); emp.setGender((short)1); emp.setImage(\u0026#34;1.img\u0026#34;); emp.setJob((short)2); emp.setEntrydate(LocalDate.of(2006,3,4)); emp.setDeptId(1); emp.setUpdateTime(LocalDateTime.now()); empMapper.update(emp); } 4.5 查询\r查询时 名称一致才能自动封装 @Test public void testSelect(){ Emp emp = empMapper.selectById(1); System.out.println(emp); } @Select(\u0026#34;select * from emp where id= #{id}\u0026#34;) Emp selectById(Integer id); 4.5.1 解决名称不一致\r4.5.1.1 sql起别名\r在sql语句中给返回字段起别名 @Select(\u0026#34;select id, username, password, name, gender, image, job, entrydate, dept_id deptId, create_time createTime, update_time updateTime from emp where id= #{id}\u0026#34;) Emp selectById(Integer id); 4.5.1.2 Result\r@Results({ @Result(column = \u0026#34;dept_id\u0026#34;, property = \u0026#34;deptId\u0026#34;), @Result(column = \u0026#34;create_time\u0026#34;, property = \u0026#34;createTime\u0026#34;), @Result(column = \u0026#34;update_time\u0026#34;, property = \u0026#34;updateTime\u0026#34;) }) @Select(\u0026#34;select * from emp where id= #{id}\u0026#34;) Emp selectById(Integer id); 4.5.1.3 MyBatis 自动映射\ra_column \u0026ndash;\u0026gt; aColumn 自动;映射 mybatis.configuration.map-underscore-to-camel-case=true 4.5.2 拼接查询 concat\r@Select(\u0026#34;select * from emp where name like \u0026#39;%${name}%\u0026#39; and gender = #{gender} and entrydate \u0026#34; + \u0026#34;between #{begin} and #{end} order by update_time desc;\u0026#34;) List\u0026lt;Emp\u0026gt; selectBy(String name, Short gender, LocalDate begin, LocalDate end); # { } 不能出现在 '' 内, 改成 $ 存在 sql 注入问题, 且性能低.\nconcat 函数 @Select(\u0026#34;select * from emp where name like concat(\u0026#39;%\u0026#39;,#{name},\u0026#39;%\u0026#39;) and gender = #{gender} and entrydate \u0026#34; + \u0026#34;between #{begin} and #{end} order by update_time desc;\u0026#34;) List\u0026lt;Emp\u0026gt; selectBy(String name, Short gender, LocalDate begin, LocalDate end); 5 Xml\rxml 文件中 \u0026lt;\u0026gt; 要转义字符 \u0026amp;gt; \u0026amp;lt; resultType 是单条数据的实体类型, 函数返回的是list 但是单条数据是Emp 类型, 因次是 org.hzl.pojo.Emp \u0026lt;mapper namespace=\u0026#34;org.hzl.mapper.EmpMapper\u0026#34;\u0026gt; \u0026lt;select id=\u0026#34;selectBy\u0026#34; resultType=\u0026#34;org.hzl.pojo.Emp\u0026#34;\u0026gt; select * from emp where name like \u0026#39;%${name}%\u0026#39; and gender = #{gender} and entrydate between #{begin} and #{end} order by update_time desc; \u0026lt;/select\u0026gt; \u0026lt;/mapper\u0026gt; // mapper 中不用写注解 List\u0026lt;Emp\u0026gt; selectBy(String name, Short gender, LocalDate begin, LocalDate end); MyBatisX 插件 6 动态sql\r6.1 if\r\u0026lt;mapper namespace=\u0026#34;org.hzl.mapper.EmpMapper\u0026#34;\u0026gt; \u0026lt;select id=\u0026#34;selectBy\u0026#34; resultType=\u0026#34;org.hzl.pojo.Emp\u0026#34;\u0026gt; select * from emp where \u0026lt;if test=\u0026#34;name!=null\u0026#34;\u0026gt; name like \u0026#39;%${name}%\u0026#39; \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;gender!=null\u0026#34;\u0026gt; and gender = #{gender} \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;begin!=null and end!=null\u0026#34;\u0026gt; and entrydate between #{begin} and #{end} \u0026lt;/if\u0026gt; order by update_time desc; \u0026lt;/select\u0026gt; \u0026lt;/mapper\u0026gt; List\u0026lt;Emp\u0026gt; list = empMapper.selectBy(\u0026#34;张\u0026#34;, null, null, null); System.out.println(list); 6.1.1 and 拼接问题\r\u0026lt;mapper namespace=\u0026#34;org.hzl.mapper.EmpMapper\u0026#34;\u0026gt; \u0026lt;select id=\u0026#34;selectBy\u0026#34; resultType=\u0026#34;org.hzl.pojo.Emp\u0026#34;\u0026gt; select * from emp where \u0026lt;if test=\u0026#34;name!=null\u0026#34;\u0026gt; name like \u0026#39;%${name}%\u0026#39; \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;gender!=null\u0026#34;\u0026gt; and gender = #{gender} \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;begin!=null and end!=null\u0026#34;\u0026gt; and entrydate between #{begin} and #{end} \u0026lt;/if\u0026gt; order by update_time desc; \u0026lt;/select\u0026gt; \u0026lt;/mapper\u0026gt; List\u0026lt;Emp\u0026gt; list = empMapper.selectBy(null, (short) 1, null, null); System.out.println(list); name 字段为null, 但下面的 and gender 多了个 and. 当条件都不满足时, where 也多了. 使用 \u0026lt;where\u0026gt; \u0026lt;/where\u0026gt; 标签包裹起来, 自动去除 and\n\u0026lt;mapper namespace=\u0026#34;org.hzl.mapper.EmpMapper\u0026#34;\u0026gt; \u0026lt;select id=\u0026#34;selectBy\u0026#34; resultType=\u0026#34;org.hzl.pojo.Emp\u0026#34;\u0026gt; select * from emp \u0026lt;where\u0026gt; \u0026lt;if test=\u0026#34;name!=null\u0026#34;\u0026gt; name like \u0026#39;%${name}%\u0026#39; \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;gender!=null\u0026#34;\u0026gt; and gender = #{gender} \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;begin!=null and end!=null\u0026#34;\u0026gt; and entrydate between #{begin} and #{end} \u0026lt;/if\u0026gt; \u0026lt;/where\u0026gt; order by update_time desc; \u0026lt;/select\u0026gt; \u0026lt;/mapper\u0026gt; \u0026lt;where\u0026gt; 实现了 and or where 的去除 6.1.2 案例 不完全更新\r需求: 有传递值就更新, 否则不更新该字段; \u0026lt;update id=\u0026#34;updateNotAll\u0026#34;\u0026gt; update emp set \u0026lt;if test=\u0026#34;username!=null\u0026#34;\u0026gt;username = #{username},\u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;name!=null\u0026#34;\u0026gt;name = #{name},\u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;gender!=null\u0026#34;\u0026gt;gender = #{gender},\u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;image!=null\u0026#34;\u0026gt;image = #{image},\u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;job!=null\u0026#34;\u0026gt;job = #{job},\u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;entrydate!=null\u0026#34;\u0026gt;entrydate = #{entrydate},\u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;deptId!=null\u0026#34;\u0026gt;dept_id = #{deptId},\u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;updateTime!=null\u0026#34;\u0026gt;update_time = #{updateTime}\u0026lt;/if\u0026gt; where id = #{id}\u0026lt;/update\u0026gt; 问题 会多个逗号 ; 使用 \u0026lt;set\u0026gt; 标签解决; \u0026lt;update id=\u0026#34;updateNotAll\u0026#34;\u0026gt; update emp \u0026lt;set\u0026gt; \u0026lt;if test=\u0026#34;username!=null\u0026#34;\u0026gt;username = #{username},\u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;name!=null\u0026#34;\u0026gt;name = #{name},\u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;gender!=null\u0026#34;\u0026gt;gender = #{gender},\u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;image!=null\u0026#34;\u0026gt;image = #{image},\u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;job!=null\u0026#34;\u0026gt;job = #{job},\u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;entrydate!=null\u0026#34;\u0026gt;entrydate = #{entrydate},\u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;deptId!=null\u0026#34;\u0026gt;dept_id = #{deptId},\u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;updateTime!=null\u0026#34;\u0026gt;update_time = #{updateTime}\u0026lt;/if\u0026gt; \u0026lt;/set\u0026gt; where id = #{id}\u0026lt;/update\u0026gt; 6.1.3 总结 逗号,where\r6.2 foreach\r\u0026lt;!-- item 遍历出来的;元素--\u0026gt; \u0026lt;!-- separator 分隔符--\u0026gt; \u0026lt;!-- open 遍历开始前面的拼接片段--\u0026gt; \u0026lt;!-- close 遍历结束后拼接的片段--\u0026gt; \u0026lt;delete id=\u0026#34;deleteByIds\u0026#34;\u0026gt; delete from emp where id in \u0026lt;foreach collection=\u0026#34;ids\u0026#34; item = \u0026#34;id\u0026#34; separator=\u0026#34;,\u0026#34; open = \u0026#34;(\u0026#34; close = \u0026#34;)\u0026#34;\u0026gt; #{id} \u0026lt;/foreach\u0026gt; \u0026lt;/delete\u0026gt; 6.3 sql 和 include\r6.3.1 问题\r代码复用性差, 若修改需修改多处. 6.3.2 sql\r\u0026lt;sql id = \u0026#34;commonSelect\u0026#34;\u0026gt; select id, username, password, name, gender, image, job, entrydate, dept_id, create_time, update_time from emp\u0026lt;/sql\u0026gt; \u0026lt;select id=\u0026#34;selectBy\u0026#34; resultType=\u0026#34;org.hzl.pojo.Emp\u0026#34;\u0026gt; \u0026lt;include refid=\u0026#34;commonSelect\u0026#34;/\u0026gt; \u0026lt;where\u0026gt; \u0026lt;if test=\u0026#34;name!=null\u0026#34;\u0026gt; name like \u0026#39;%${name}%\u0026#39; \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;gender!=null\u0026#34;\u0026gt; and gender = #{gender} \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;begin!=null and end!=null\u0026#34;\u0026gt; and entrydate between #{begin} and #{end} \u0026lt;/if\u0026gt; \u0026lt;/where\u0026gt; order by update_time desc;\u0026lt;/select\u0026gt; 插入返回主键\rkeyProperty 指定赋值给传入参数的哪个属性, 这里是赋值给 dish.id dishMapper.insert(dish); \u0026lt;insert id=\u0026#34;insert\u0026#34; useGeneratedKeys=\u0026#34;true\u0026#34; keyProperty=\u0026#34;id\u0026#34;\u0026gt; insert into dish(name, category_id, price, image, description, status, create_time, update_time, create_user, update_user) values (#{name},#{categoryId},#{price},#{image},#{description},#{status},#{createTime},#{updateTime},#{createUser},#{updateUser})\u0026lt;/insert\u0026gt; 多表查询\r\u0026lt;!-- select d.*,c.name categoryName from dish d left outer join category c on d.category_id = c.id;--\u0026gt; \u0026lt;select id=\u0026#34;pageQuery\u0026#34; resultType=\u0026#34;com.sky.vo.DishVO\u0026#34;\u0026gt; select d.*,c.name categoryName from dish d left outer join category c on d.category_id = c.id \u0026lt;where\u0026gt; \u0026lt;if test=\u0026#34;name!=null and name!=\u0026#39;\u0026#39;\u0026#34;\u0026gt;d.name like concat(\u0026#39;%\u0026#39;,#{name},\u0026#39;%\u0026#39;)\u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;categoryId!=null\u0026#34;\u0026gt;and d.categoryId=#{categoryId}\u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;status!=null\u0026#34;\u0026gt;and d.status = #{status}\u0026lt;/if\u0026gt; \u0026lt;/where\u0026gt; order by d.create_time desc \u0026lt;/select\u0026gt; 6.4 总结\r编写 xml 文件时 , 先在console中写好 sql 语句, 在替换标签. 编写xml 文件使 , String类型的 要判断是否为空 6.5 坑\rupdate 逗号问题, 检查sql 语句 每对后面逗号 ","date":"2026-04-11T00:00:00Z","permalink":"/p/mybatis-%E5%9F%BA%E7%A1%80%E4%B8%8E%E4%BD%BF%E7%94%A8/","title":"MyBatis 基础与使用"},{"content":"MyBatis-Plus 入门与使用\r先 Mapper 中定义了很多方法 Service 中定义了很多方法 Mapper方法\rmybatisplus 使得单表查询很方便 BaseMapper 内置了很多方法 继承时要指定 泛型的类型 常见注解\r约定\r自定义配置\r不符合约定就要自定义配置 更多注解 https://baomidou.com/reference/annotation/ 常见配置\r核心功能\r条件构造器\rwrapper 就是条件构造器 demo\r@Test void testQueryWrapper() { QueryWrapper\u0026lt;User\u0026gt; wrapper = new QueryWrapper\u0026lt;\u0026gt;(); wrapper.select(\u0026#34;id\u0026#34;, \u0026#34;username\u0026#34;, \u0026#34;info\u0026#34;, \u0026#34;balance\u0026#34;) .like(\u0026#34;username\u0026#34;, \u0026#34;o\u0026#34;) .ge(\u0026#34;balance\u0026#34;, 1000); List\u0026lt;User\u0026gt; users = userMapper.selectList(wrapper); System.out.println(users); } @Test void testUpdateQueryWrapper() { // user 是数据 User user = new User(); user.setBalance(2000); // wrapper 是条件 QueryWrapper\u0026lt;User\u0026gt; wrapper = new QueryWrapper\u0026lt;User\u0026gt;().eq(\u0026#34;username\u0026#34;, \u0026#34;jack\u0026#34;); userMapper.update(user, wrapper); } @Test void testUpdateWrapper() { List\u0026lt;Long\u0026gt; ids = List.of(1l, 2l, 3l, 4l); UpdateWrapper\u0026lt;User\u0026gt; wrapper = new UpdateWrapper\u0026lt;User\u0026gt;() .setSql(\u0026#34;balance = balance-200\u0026#34;) .in(\u0026#34;id\u0026#34;, ids); userMapper.update(null, wrapper); } // 推荐使用 lambda 模式 @Test void lambdaQueryWrapper() { LambdaQueryWrapper\u0026lt;User\u0026gt; wrapper = new LambdaQueryWrapper\u0026lt;\u0026gt;(); wrapper.select(User::getId, User::getUsername, User::getInfo, User::getBalance) .like(User::getUsername, \u0026#34;o\u0026#34;) .ge(User::getBalance, 1000); List\u0026lt;User\u0026gt; users = userMapper.selectList(wrapper); System.out.println(users); } 自定义sql\rwhere 使用 wrapper 生成, 其他使用自定义 下面的 setSql 在业务层操作 sql 语句, 这是不推荐的 select 要在业务层进行拼接, 这也是不推荐的 demo\r@Test void testCustomUpdateWrapper() { List\u0026lt;Long\u0026gt; ids = List.of(1l, 2l, 3l, 4l); int amount = 200; // 只写 where条件 QueryWrapper\u0026lt;User\u0026gt; wrapper = new QueryWrapper\u0026lt;User\u0026gt;() .in(\u0026#34;id\u0026#34;,ids); //调用自定义方法 userMapper.updateBalance(wrapper, amount); } void updateBalance(@Param(\u0026#34;ew\u0026#34;) QueryWrapper\u0026lt;User\u0026gt; wrapper,@Param(\u0026#34;amount\u0026#34;) int amount); \u0026lt;update id=\u0026#34;updateBalance\u0026#34;\u0026gt; update user set balance = balance- #{amount} ${ew.customSqlSegment}\u0026lt;/update\u0026gt; //${ew.customSqlSegment} 拼接前面定义的where条件 service 接口\ruserServiceImpl 继承ServiceImpl\u0026lt;UserMapper, User\u0026gt;, 实现 UserService 自定义 Service 继承 IService , 自定义实现类实现自定义接口, 继承默认实现类 Service 中有 baseMapper 这里是父类, 实际获取的是子类 : 上面使用 UserMapper extends BaseMapper ;因此这里获得的是 UserMapper 批量新增\rrewriteBatchStatements=true demo\rpublic interface IUserService extends IService\u0026lt;User\u0026gt; { } public class UserServiceImpl extends ServiceImpl\u0026lt;UserMapper,User\u0026gt; implements IUserService { } @Test void testQuery() { List\u0026lt;User\u0026gt; list = iUserService.listByIds(List.of(1l,2l,3l,4l)); list.forEach(System.out::println); } lambdaQuery\r条件查询 @Override public List\u0026lt;User\u0026gt; queryUsers(String name, Integer status, Integer minBalance, Integer maxBalance) { List\u0026lt;User\u0026gt; list = lambdaQuery() .like(name != null, User::getUsername, name) .eq(status != null, User::getStatus, status) .ge(minBalance != null, User::getBalance, minBalance) .le(maxBalance != null, User::getBalance, maxBalance) .list(); return list; } 选择性更新 lambdaUpdate() .set(User::getBalance, remain) .set(remain == 0, User::getStatus, 2) .eq(User::getId, id) .eq(User::getBalance,user.getBalance()) .update(); 总结\r简单业务如: 根据 id 查用户可以直接在 controller 操作 @RequiredArgsConstructor public class UserController { private final IUserService iUserService; @GetMapping(\u0026#34;/{id}\u0026#34;) public UserVO queryById(@PathVariable Long id){ User byId = iUserService.getById(id); UserVO userVO = new UserVO(); BeanUtils.copyProperties(byId,userVO); return userVO; } } 复杂业务 使用 Service , 如果 Sql 不复杂可以 不用 Mapper @Override public void deductMoney(Long id, int money) { // 查询id User user = getById(id); // 校验用户状态 if(user==null ||user.getStatus()==2){ throw new RuntimeException(\u0026#34;用户状态异常\u0026#34;); } // 校验余额 if(user.getBalance()\u0026lt;money){ throw new RuntimeException(\u0026#34;用户余额不足!\u0026#34;); } // 扣除余额, baseMapper.deduct(id,money); } 复杂业务且复杂 Sql 可以使用 Mapper @Update(\u0026#34;update user set balance = balance-#{money} where id = #{id}\u0026#34;) void deduct(@Param(\u0026#34;id\u0026#34;) Long id,@Param(\u0026#34;money\u0026#34;) int money); 业务层中简单条件直接调用函数, 复杂的条件可以使用 lambda @Override public List\u0026lt;User\u0026gt; queryUsers(String name, Integer status, Integer minBalance, Integer maxBalance) { List\u0026lt;User\u0026gt; list = lambdaQuery() .like(name != null, User::getUsername, name) .eq(status != null, User::getStatus, status) .ge(minBalance != null, User::getBalance, minBalance) .le(maxBalance != null, User::getBalance, maxBalance) .list(); return list; } 静态工具\r和 iservice 不同在于需要传入 class 字节码文件 使用场景\rAService 中注入 BService, BService 中注入AService 导致循环注入, 使用静态方法可以避免. 逻辑删除\r枚举处理器\r加注解 添加配置 json处理器\r","date":"2026-04-11T00:00:00Z","permalink":"/p/mybatis-plus-%E5%85%A5%E9%97%A8%E4%B8%8E%E4%BD%BF%E7%94%A8/","title":"MyBatis-Plus 入门与使用"},{"content":"MySQL 数据库基础\r基本操作\r初始化\rmysqld --initialize-insecure //data 文件夹\rmysqld -install // 安装服务\rnet start mysql // 启动服务 修改默认密码\rmysqladmin -u root password 1234 登录\rmysql -uroot -p1234 [-hlocalhost -P3306] //可选 登录docker Desktop 将 内部3306 映射到外部的 3305\nmysql -P3305 -uroot -phzl 库\rcreate database db01;\rshow databases;\rselect database(); //显示当前数据库\ruse db01;\rdrop database if exists db02; 单表\r表\r约束\r还有 auto_increment\n数据类型\rchar(10) 一定是10个字符 varchar(10)最多存10个, 小于10时按实际长度存储. create table tb_user( id int comment \u0026#39;唯一id\u0026#39;, username varchar(20) comment \u0026#39;用户名\u0026#39;, name varchar(10) comment \u0026#39;姓名\u0026#39;, age int comment \u0026#39;年龄\u0026#39;, gender char(1) comment \u0026#39;性别\u0026#39; ) comment \u0026#39;用户表\u0026#39;; create table tb_user( id int primary key auto_increment comment \u0026#39;唯一id\u0026#39;, username varchar(20) not null unique comment \u0026#39;用户名\u0026#39;, name varchar(10) not null comment \u0026#39;姓名\u0026#39;, age int comment \u0026#39;年龄\u0026#39;, gender char(1) default \u0026#39;男\u0026#39; comment \u0026#39;性别\u0026#39; ) comment \u0026#39;用户表\u0026#39;; 示例\r常见操作\rdrop table tb_user;\rdesc tb_emp; //展示表结构\rshow create table tb_emp; //展示建表语句 增删改\rinsert\rinsert into tb_emp(username, name, gender ,createDate,updateTime) values(\u0026#39;wuji\u0026#39;,\u0026#39;wujiname\u0026#39;,1,now(),now()); insert into tb_emp values(null,\u0026#39;zhiruo\u0026#39;,\u0026#39;123\u0026#39;,\u0026#39;周芷若\u0026#39;,1,\u0026#39;1.jpg\u0026#39;, 1,\u0026#39;2020-1-1\u0026#39;,now(),now()); insert into tb_emp(username, name, gender ,createDate,updateTime) values(\u0026#39;LiMing\u0026#39;,\u0026#39;liming\u0026#39;,1,now(),now()), (\u0026#39;xiaohong\u0026#39;,\u0026#39;xiaohong\u0026#39;,2,now(),now()); update\rupdate tb_emp set name = \u0026#39;张三\u0026#39; ,updateTime = now() where id=1; delete\rdelete from tb_emp where id = 1; -- 删除整个表中内容 delete from tb_emp; 查\rselect from\rwhere\rselect * from tb_emp where name = \u0026#39;杨逍\u0026#39;; select * from tb_emp where id\u0026lt;=5; select * from tb_emp where job is null; select * from tb_emp where job is not null; select * from tb_emp where password!=\u0026#39;123456\u0026#39;; select * from tb_emp where entrydate\u0026gt;=\u0026#39;2000-1-1\u0026#39; and entrydate\u0026lt;=\u0026#39;2010-1-1\u0026#39;; select * from tb_emp where entrydate between \u0026#39;2000-1-1\u0026#39; and \u0026#39;2010-1-1\u0026#39;; select * from tb_emp where entrydate between \u0026#39;2000-1-1\u0026#39; and \u0026#39;2010-1-1\u0026#39; and gender = 2; select * from tb_emp where job in (2,3,4); -- 两个字的员工 select * from tb_emp where name like \u0026#39;__\u0026#39;; select * from tb_emp where name like \u0026#39;张%\u0026#39;; 聚合函数\rselect count(id) from tb_emp; -- null 不被count 计算, 因此一般选择非空常量. select count(job) from tb_emp; select count(\u0026#39;A\u0026#39;) from tb_emp; select count(*) from tb_emp; 推荐count(*) 分组 group having\r分组之后, 只能返回分组字段或聚合函数, 比如 select gender,name from table group by name 是错误的, 一旦加上group by, select 字段只能是 被分组字段或者是其余字段的聚合函数. select gender,count(*) from tb_emp group by gender; select job,count(*) from tb_emp where entrydate\u0026lt;=\u0026#39;2015-1-1\u0026#39; group by job having count(*)\u0026gt;=2; order by\rASC 升序 DESC 降序 select * from tb_emp order by entrydate; select * from tb_emp order by entrydate desc; -- 入职时间升序, 入职时间相同按更新时间降序; select * from tb_emp order by entrydate,update_time desc; limit\r起始索引 从0开始; select * from tb_emp limit 0,5; select * from tb_emp limit 5,5; 多表\r约束\r一对多的关系, 在多的一方添加外键关联一的主键. 例如部门表和员工表, 一个部门有多个员工, 应在员工表添加外键关联部门表id. alter table tb_emp add constraint tb_emp_tb_dept_id_fk foreign key (dept_id) references tb_dept (id); 使用代码保证数据一致性. 建表\r设计: 套餐表和菜品表是多对多, 因此使用另外一张表记录. 查询\r内连接\r如果表A中为null, 那么交集中不会有这条数据 , 想包含某表的所有数据(包含null) 要使用外连接. select tb_emp.name,tb_dept.name from tb_dept,tb_emp where tb_emp.dept_id=tb_dept.id; select tb_emp.name,tb_dept.name from tb_dept inner join tb_emp on tb_emp.dept_id=tb_dept.id; 某人 的dept_id为null, 该数据不会被查询到. 外连接\rselect tb_emp.name,tb_dept.name from tb_emp left outer join tb_dept on tb_emp.dept_id=tb_dept.id; -- 如下, 做表完全存在. 子查询\r标量子查询\r返回是一个数, 用\u0026gt;, \u0026lt;, =, != 连接 select * from tb_emp where dept_id = (select id from tb_dept where name = \u0026#39;教研部\u0026#39;); select * from tb_emp where entrydate\u0026gt;(select entrydate from tb_emp where name = \u0026#39;方东白\u0026#39;); 列子查询\r返回一个列, 用 in, not in 连接 -- 列子查询 -- 查 教研部和咨询部的员工 select * from tb_emp where dept_id in(select id from tb_dept where name =\u0026#39;教研部\u0026#39; or name = \u0026#39;咨询部\u0026#39;); 行子查询\r返回值是一行(可以是多列); 使用 \u0026lt; \u0026gt; = in not in 连接. -- 行子查询 -- 查询与 韦一笑 入职日期和职位都相同的 select entrydate,job from tb_emp where name = \u0026#39;韦一笑\u0026#39;; select * from tb_emp where (entrydate , job) = (select entrydate,job from tb_emp where name = \u0026#39;韦一笑\u0026#39;); 表子查询\r多为临时表, 在from之后. 使用in连接; -- 查询入职日期是 2006-01-01 之后的员工信息和职位名称 select * from tb_emp where entrydate\u0026gt;\u0026#39;2006-01-01\u0026#39;; select e.*,tb_dept.name from (select * from tb_emp where entrydate\u0026gt;\u0026#39;2006-01-01\u0026#39;) e,tb_dept where e.dept_id = tb_dept.id; 事务\r索引\rcreate index index_name on table(column); 6000000 条数据\n没创建索引时是全表扫描, 有索引时,是树形结构 实际是 B+树. 创建索引后 存在/data/datebase_name/table_name.idb, 索引和数据库文件是一起存放的. 会占用空间. 增删改时要维护数据结构. B+树\r数据只存在叶子节点 叶子节点双向循环链表. 查找过程\r查找 29 , 进行3次磁盘io即可. 语法\r","date":"2026-04-11T00:00:00Z","permalink":"/p/mysql-%E6%95%B0%E6%8D%AE%E5%BA%93%E5%9F%BA%E7%A1%80/","title":"MySQL 数据库基础"},{"content":"Nacos 注册中心\rdocker 示例\r运行mysql, 先执行 nacos.sql 运行 docker-compose custom.env 配置数据库 PREFER_HOST_MODE=hostname\rMODE=standalone\rSPRING_DATASOURCE_PLATFORM=mysql\rMYSQL_SERVICE_HOST=192.168.150.101\rMYSQL_SERVICE_DB_NAME=nacos\rMYSQL_SERVICE_PORT=3306\rMYSQL_SERVICE_USER=root\rMYSQL_SERVICE_PASSWORD=123\rMYSQL_SERVICE_DB_PARAM=characterEncoding=utf8\u0026amp;connectTimeout=1000\u0026amp;socketTimeout=3000\u0026amp;autoReconnect=true\u0026amp;useSSL=false\u0026amp;allowPublicKeyRetrieval=true\u0026amp;serverTimezone=Asia/Shanghai docker compose -f docker-compose.yml up -d version: \u0026#34;3.8\u0026#34;\rservices:\rmysql:\rimage: mysql\rcontainer_name: mysql\rports:\r- \u0026#34;3306:3306\u0026#34;\renvironment:\rTZ: Asia/Shanghai\rMYSQL_ROOT_PASSWORD: 123\rvolumes:\r- \u0026#34;./mysql/conf:/etc/mysql/conf.d\u0026#34;\r- \u0026#34;./mysql/data:/var/lib/mysql\u0026#34;\r- \u0026#34;./mysql/init:/docker-entrypoint-initdb.d\u0026#34;\rnetworks:\r- hm-net\rhmall:\rbuild:\rcontext: .\rdockerfile: Dockerfile\rcontainer_name: hmall\rports:\r- \u0026#34;8080:8080\u0026#34;\rnetworks:\r- hm-net\rdepends_on:\r- mysql\rnginx:\rimage: nginx\rcontainer_name: nginx\rports:\r- \u0026#34;18080:18080\u0026#34;\r- \u0026#34;18081:18081\u0026#34;\rvolumes:\r- \u0026#34;./nginx/nginx.conf:/etc/nginx/nginx.conf\u0026#34;\r- \u0026#34;./nginx/html:/usr/share/nginx/html\u0026#34;\rdepends_on:\r- hmall\rnetworks:\r- hm-net\rnacos:\rimage: nacos/nacos-server:v2.1.0-slim\rcontainer_name: nacos2\renv_file:\r- ./nacos/custom.env\rports:\r- 8848:8848\r- 9848:9848\r- 9849:9849\rrestart: always\rnetworks:\r- hm-net\rnetworks:\rhm-net:\rname: hmall 用户名密码 nacos:nacos springboot 使用\r引入依赖 \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.alibaba.cloud\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-cloud-starter-alibaba-nacos-discovery\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; 设置配置地址 服务发现 示例 // 1.获取商品id Set\u0026lt;Long\u0026gt; itemIds = vos.stream().map(CartVO::getItemId).collect(Collectors.toSet()); // 2.1 根据服务名称获取服务实例列表 List\u0026lt;ServiceInstance\u0026gt; instances = discoveryClient.getInstances(\u0026#34;item-service\u0026#34;); if(instances.isEmpty()){ return; } // 2.2 根据策略选择一个实例 ServiceInstance serviceInstance = instances.get(RandomUtil.randomInt(instances.size())); // 2.查询商品 // TODO 获取数据 // List\u0026lt;ItemDTO\u0026gt; items = itemService.queryItemByIds(itemIds); // 2.1 利用 resttemplate 获取响应 ResponseEntity\u0026lt;List\u0026lt;ItemDTO\u0026gt;\u0026gt; ids = restTemplate.exchange( serviceInstance.getUri()+ \u0026#34;/items?ids={ids}\u0026#34;, HttpMethod.GET, null, new ParameterizedTypeReference\u0026lt;List\u0026lt;ItemDTO\u0026gt;\u0026gt;() { }, Map.of(\u0026#34;ids\u0026#34;, CollUtils.join(itemIds, \u0026#34;,\u0026#34;)) ); ","date":"2026-04-11T00:00:00Z","permalink":"/p/nacos-%E6%B3%A8%E5%86%8C%E4%B8%AD%E5%BF%83/","title":"Nacos 注册中心"},{"content":"OpenFeign 服务调用\r使用 nacos 仍然需要手写 http 请求 openFeign 简化手写 在cart-service中，定义一个新的接口，编写Feign客户端：\npackage com.hmall.cart.client; import com.hmall.cart.domain.dto.ItemDTO; import org.springframework.cloud.openfeign.FeignClient; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; import java.util.List; @FeignClient(\u0026#34;item-service\u0026#34;) public interface ItemClient { @GetMapping(\u0026#34;/items\u0026#34;) List\u0026lt;ItemDTO\u0026gt; queryItemByIds(@RequestParam(\u0026#34;ids\u0026#34;) Collection\u0026lt;Long\u0026gt; ids); } openFeign 连接池\ropenFeign 最佳实践\r效果很好, 大型服务可以使用 结构复杂, 增加了工作量 如果每个微服务是一个 Project 可以使用这种方式 代码耦合度增加了 如果微服务是通过 maven聚合实现的, 可以使用这种方法 demo\r因为 cart 服务的包是 com.hmall.cart , 而 client 在 com.hmall.api.client , 因此不会扫描到, 所以\nopenFeign 日志\r总结\ropenfeign 网关\r跨包\r","date":"2026-04-11T00:00:00Z","permalink":"/p/openfeign-%E6%9C%8D%E5%8A%A1%E8%B0%83%E7%94%A8/","title":"OpenFeign 服务调用"},{"content":"Redis 入门基础\r简介\r内存 速度款 热点数据 key-value 存储数据 连接\r//启动服务\r.\\redis-server.exe .\\redis.conf //连接服务\r.\\redis-cli.exe -h localhost -p 6379 密码\rredis.conf文件 requirepass 1234 .\\redis-cli.exe -h localhost -p 6379 -a 1234 数据类型\rkey 字符串 value string, hash, list, set, sorted set/zset 常用命令\r字符串\r哈希\r\u0026gt; hset 100 name xiaoming\r1\r\u0026gt; hset 100 age 22\r1\r\u0026gt; hget 100 name\rxiaoming\r\u0026gt; hget 100 age\r22\r\u0026gt; hdel 100 name\r1\r\u0026gt; hset 100 name xiaoming\r1\r\u0026gt; hkeys 100\rage\rname\r\u0026gt; hvals 100\r22\rxiaoming 列表\rlpush从左侧插入,插入到索引小的位置; rpush从右侧插入, 插入到索引大的位置\nlpop从左侧(索引小)弹出; rpop从右侧弹出;\n插入头部指左侧 lpush; rpush 指右侧\nlocal connected! lpush mylist c py java 3 lpush mylist go 4 lrange mylist 0 -1 go java py c\n### 集合\r![](Pasted-image-20250110152912.png)\r### 有序集合\r![](Pasted-image-20250110153557.png) zrange zset1 0 -1 a c b zrange zset1 0 -1 withscores a 10 c 10.2 b 10.5\nzincrby zset1 5.0 a 15\n### 通用命令\r![](Pasted-image-20250110155304.png) ","date":"2026-04-11T00:00:00Z","permalink":"/p/redis-%E5%85%A5%E9%97%A8%E5%9F%BA%E7%A1%80/","title":"Redis 入门基础"},{"content":"Redis 实战项目 - 黑马点评本地整理\r文章目录\r一、项目知识点介绍 二、短信登录 1、导入黑马点评项目 （1）导入SQL （2）项目架构模型 （3）导入后端项目 （4）导入前端项目 2、基于Session实现登录流程 3、实现阿里云发送短信验证码功能 4、实现登录拦截和登录验证功能 5、集群的Session共享问题 6、基于Redis实现共享Session登录流程梳理 （1）设计存储的数据结构 （2）设计Key的细节 （3）整体访问流程 7、基于Redis实现短信登录 8、解决登录状态刷新问题 （1）初始方案问题分析 （2）优化登录拦截器 三、商户查询缓存 1、添加商户缓存 2、查询店铺类型缓存 3、缓存一致性问题 （1）常见的缓存更新策略 （2）主动更新策略的三种方案 （3）双写方案 操作缓存和数据库需要考虑的三个问题 4、实现商铺查询的数据库与缓存双写一致 5、缓存穿透的解决方案 6、缓存雪崩的解决方案 7、缓存击穿的解决方案 8、利用互斥锁解决缓存击穿问题 9、利用逻辑过期解决缓存击穿问题 10、封装Redis工具类 四、优惠券秒杀 1、全局唯一ID （1）数据库自增ID存在的问题 （2）基于Redis自增器实现分布式全局ID 2、实现秒杀下单优惠券功能 3、单体服务下一人多单超卖问题 4、乐观锁解决一人多单超卖问题 5、单体服务下一人一单超卖问题 6、悲观锁解决单体服务下一人一单超卖问题 7、集群服务下一人一单超卖问题 （1）搭建服务集群并实现负载均衡 （2）一人一单的并发安全问题 8、分布式锁介绍 9、Redis分布式锁解决集群超卖问题 10、Redis分布式锁优化 （1）Redis分布式锁超时误删问题 （2）解决Redis分布式锁超时误删问题 （3）Redis分布式锁释放锁原子性问题 （4）解决Redis分布式锁释放锁原子性问题 11、Redisson （1）介绍 （2）Redisson实现分布式锁 （3）Redisson分布式锁原理 ① 可重入锁的原理 ② Redisson源码流程原理解析 12、Redis异步秒杀优化 （1）秒杀业务性能测试 （2）异步秒杀思路分析 （3）改进秒杀业务，提高并发性能 13、Redis消息队列实现异步秒杀 （1）基于List结构模拟消息队列 （2）基于PubSub的消息队列 （3）基于Stream的消息队列 ① Stream的单消费模式 ② Stream的消费者组模式 （4）Stream消息队列优化异步秒杀 五、达人探店 1、发布探店笔记 2、查看探店笔记 3、Set实现点赞功能 4、Sorted Set实现点赞排行榜功能 六、好友关注 1、关注和取关 2、共同关注 3、关注推送 （1）Feed流实现方案 （2）推送到粉丝收件箱 （3）实现滚动分页查询 七、附近商铺 1、GEO的基本用法 2、附近商户搜索 八、用户签到 1、BitMap的基本用法 2、实现签到功能 3、实现统计连续签到天数 九、UV统计 1、HyperLogLog的基本用法 2、实现百万数据的统计 一、项目知识点介绍\r本项目包含了Redis各种应用技巧和解决方案，具体包括：\n短信登录：使用Redis共享session来实现。 商户查询缓存：企业的缓存使用技巧、缓存击穿，缓存穿透，缓存雪崩等问题解决。 优惠卷秒杀：Redis的计数器功能、 结合Lua脚本完成高性能的Redis操作、Redis分布式锁、Redis的三种消息队列。 好友关注：基于Set集合的关注、取消关注，共同关注、消息推送等功能。 达人探店：基于List来完成点赞列表的操作，同时基于SortedSet来完成点赞排行榜功能。 附近的商户：利用Redis的GEOHash来完成对于地理坐标的操作。 用户签到：使用Redis的BitMap数据统计功能。 UV统计：使用Redis的HyperLogLog来完成统计功能。 ![](Java/Java Web/Redis/files/b9e2f218c2ca0f4da742f3d7c377f447_MD5.png)\n✅ 视频地址：[实战篇] 黑马程序员Redis入门到实战教程，深度透析redis底层原理+redis分布式锁+企业解决方案+黑马点评实战项目\n💡 Redis基础篇在这里👉【Redis基础篇】超详细♥Redis安装教程、5种常用数据结构和常见命令、Jedis和SpringDataRedis的使用\n📂 Redis实战篇官方代码资料\n📚 本文代码gitee仓库地址\n二、短信登录\r1、导入黑马点评项目\r（1）导入SQL\r首先，创建名为hmdp数据库，执行hmdp.sql脚本：\n// 此处省略sql文件和导入步骤\n注意：MySQL的版本采用5.7及以上，最好是8.0版本\n![](Java/Java Web/Redis/files/582ca60289d228b524edae9f26dd207c_MD5.png)\n（2）项目架构模型\r![](Java/Java Web/Redis/files/3326573af54b9d0c220680c2961ae87c_MD5.png)\nNginx服务器作为静态资源服务器，部署前端项目，经过Nginx负载均衡分流到下游tomcat服务器。在高并发场景下，会选择使用MySQL集群，同时为了进一步降低MySQL的压力，同时增加访问的性能，我们也会加入Redis，同时使用Redis集群使得Redis对外提供更好的服务。\n（3）导入后端项目\r后端项目代码导入idea后，修改yaml配置文件中mysql和redis相关配置信息。\n![](Java/Java Web/Redis/files/748a1e7db85022128594e4ead5fe66dd_MD5.png)\n启动后端项目后，在浏览器访问：http://localhost:8081/shop-type/list，如果可以看到app界面，则运行成功。\n（4）导入前端项目\r前端项目代码已经放在了nginx-1.18.0/html 下，在nginx所在目录下打开一个CMD窗口，输入start nginx.exe即可启动前端项目。\n![](Java/Java Web/Redis/files/dc3f713be0e863aba3359f5e9d98ccf6_MD5.png)\n启动前端项目后，在浏览器访问：http://localhost:8080，如果可以看到app界面，则运行成功。\n2、基于Session实现登录流程\r发送验证码：\n用户在提交手机号后，会校验手机号是否合法，如果不合法，则要求用户重新输入手机号。\n如果手机号合法，后台此时生成对应的验证码，同时将验证码保存到session中，然后再通过短信的方式将验证码发送给用户。\n短信验证码登录、注册：\n用户将验证码和手机号进行输入，后台从session中拿到当前验证码，然后和用户输入的验证码进行校验，如果不一致，则无法通过校验；如果一致，则后台根据手机号查询用户，如果用户不存在，则为用户创建账号信息，保存到数据库，无论是否存在，都会将用户信息保存到session中，创建session时tomcat会自动生成JsessionId写到用户浏览器的Cookie里，方便后续获得当前登录信息。\n校验登录状态:\n用户在请求时候，会从cookie中携带着JsessionId到后台，后台通过JsessionId从session中拿到用户信息，也就是说登录凭证就是JsessionId。如果没有session信息，则进行拦截；如果有session信息，则将用户信息保存到threadLocal中，并且放行。\n![](Java/Java Web/Redis/files/7cc7f2e185c17cf52fbecc1a73263e53_MD5.png)\nThreadLocal是一个线程域对象，在我们的业务中，每一个请求到达微服务都是一个独立的线程，如果没有用ThreadLocal，直接把用户保存到一个本地变量，就有可能出现多线程环境下的并发修改安全问题。而将用户保存在ThreadLocal中，它会在每一个线程的内部创建一个Map将数据保存，这样每一个线程都有自己独立的存储空间，与其他请求线程相互之间没有干扰，实现了线程隔离的效果。\n3、实现阿里云发送短信验证码功能\r发送手机验证码页面流程 ![](Java/Java Web/Redis/files/e593685a97adc72e98d555bf93181d4d_MD5.png)\n发送验证码 /** * 发送手机验证码 */ @PostMapping(\u0026#34;code\u0026#34;) public Result sendCode(@RequestParam(\u0026#34;phone\u0026#34;) String phone, HttpSession session) { // 发送短信验证码并保存验证码 return userService.sendCode(phone, session); } /** * 发送手机验证码 */ @Override public Result sendCode(String phone, HttpSession session) { // 校验手机号格式是否有效 if (RegexUtils.isPhoneInvalid(phone)) { // 格式不符合，返回错误信息 return Result.fail(\u0026#34;手机号格式错误！\u0026#34;); } // 格式有效，生成验证码 String code = RandomUtil.randomNumbers(6); // 随机6位数字 // 保存验证码到session session.setAttribute(phone, code); // 模拟发送短信验证码 log.debug(\u0026#34;向{}发送短信验证码成功，验证码：{}\u0026#34;, phone, code); // 返回ok return Result.ok(); } 由于这里是日志输出模拟发送验证码，如果我们真的想要给手机发送验证码，需要去开通阿里云短信服务，申请签名和短信模板，阿里云SMS短信服务官方帮助文档：https://help.aliyun.com/zh/sms/。\n去官网申请和配置好AK密钥对后，就可以使用下面的工具类发送阿里云短信啦~\n导入SMS依赖坐标 \u0026lt;!-- 阿里云短信SMS --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.aliyun\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;dysmsapi20170525\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.0.0\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; 阿里云短信验证码发送工具类SMSUtils（支持双参数短信模板） /** * 阿里云短信验证码发送工具类 */ public class SMSUtils { /** * 发送短信（双参数模板） * \u0026lt;phoneNumbers\u0026gt;: 接收短信的手机号码，多个用英文逗号隔开 * \u0026lt;signNameJson\u0026gt;: 短信签名名称，eg: \u0026#34;阿里云\u0026#34; * \u0026lt;templateCode\u0026gt;: 短信模板CODE * \u0026lt;templateParamJson\u0026gt;: 短信模板变量对应的实际值，eg：{\u0026#34;code\u0026#34;:\u0026#34;1234\u0026#34;} * * @param signName 签名 * @param templateCode 模板 * @param phoneNumbers 手机号 * @param code 验证码 * @param time 验证码有效期（单位：秒） */ public static void sendMessage(String signName, String templateCode, String phoneNumbers, String code, int time) { // 初始化请求客户端 Client client = null; try { client = SMSUtils.createClient(); } catch (Exception e) { throw new RuntimeException(e); } // 构造请求对象，请填入请求参数值 SendSmsRequest sendSmsRequest = new SendSmsRequest() .setPhoneNumbers(phoneNumbers) .setSignName(signName) .setTemplateCode(templateCode) .setTemplateParam(\u0026#34;{\\\u0026#34;code\\\u0026#34;:\\\u0026#34;\u0026#34; + code + \u0026#34;\\\u0026#34;,\\\u0026#34;time\\\u0026#34;:\\\u0026#34;\u0026#34; + time + \u0026#34;\\\u0026#34;}\u0026#34;); // 获取响应对象 SendSmsResponse sendSmsResponse = null; try { sendSmsResponse = client.sendSms(sendSmsRequest); } catch (Exception e) { throw new RuntimeException(e); } // 响应包含服务端响应的 body 和 headers System.out.println(toJSONString(sendSmsResponse)); //System.out.println(sendSmsResponse.getBody()); } private static Client createClient() throws Exception { Config config = new Config() // 配置 AccessKey ID，请确保代码运行环境设置了环境变量 ALIBABA_CLOUD_ACCESS_KEY_ID。 .setAccessKeyId(System.getenv(\u0026#34;ALIBABA_CLOUD_ACCESS_KEY_ID\u0026#34;)) // 配置 AccessKey Secret，请确保代码运行环境设置了环境变量 ALIBABA_CLOUD_ACCESS_KEY_SECRET。 .setAccessKeySecret(System.getenv(\u0026#34;ALIBABA_CLOUD_ACCESS_KEY_SECRET\u0026#34;)); // 配置 Endpoint config.endpoint = \u0026#34;dysmsapi.aliyuncs.com\u0026#34;; return new Client(config); } /** * 发送短信（单参数模板） * \u0026lt;phoneNumbers\u0026gt;: 接收短信的手机号码，多个用英文逗号隔开 * \u0026lt;signNameJson\u0026gt;: 短信签名名称，eg: \u0026#34;阿里云\u0026#34; * \u0026lt;templateCode\u0026gt;: 短信模板CODE * \u0026lt;templateParamJson\u0026gt;: 短信模板变量对应的实际值，eg：{\u0026#34;code\u0026#34;:\u0026#34;1234\u0026#34;} * * @param signName 签名 * @param templateCode 模板 * @param phoneNumbers 手机号 * @param code 验证码 */ public static void sendMessage(String signName, String templateCode, String phoneNumbers, String code) { // 初始化请求客户端 Client client = null; try { client = SMSUtils.createClient(); } catch (Exception e) { throw new RuntimeException(e); } // 构造请求对象，请填入请求参数值 SendSmsRequest sendSmsRequest = new SendSmsRequest() .setPhoneNumbers(phoneNumbers) .setSignName(signName) .setTemplateCode(templateCode) .setTemplateParam(\u0026#34;{\\\u0026#34;code\\\u0026#34;:\\\u0026#34;\u0026#34; + code + \u0026#34;\\\u0026#34;}\u0026#34;); // 获取响应对象 SendSmsResponse sendSmsResponse = null; try { sendSmsResponse = client.sendSms(sendSmsRequest); } catch (Exception e) { throw new RuntimeException(e); } // 响应包含服务端响应的 body 和 headers System.out.println(toJSONString(sendSmsResponse)); } } 调用工具类发送短信 // 发送验证码，有效期LOGIN_CODE_TTL分钟 SMSUtils.sendMessage(\u0026#34;签名signName\u0026#34;, \u0026#34;SMS_474225xxx\u0026#34;, phone, code, LOGIN_CODE_TTL); log.debug(\u0026#34;向{}发送短信验证码成功，验证码：{}\u0026#34;, phone, code); 登录页面流程 ![](Java/Java Web/Redis/files/28f7446535548a63b94bb6da13cc42d2_MD5.png)\n登录、注册 /** * 登录功能 */ @PostMapping(\u0026#34;/login\u0026#34;) public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){ // 实现登录功能 return userService.login(loginForm, session); } /** * 登录、注册 * @param loginForm 验证码登录：手机号、验证码；密码登录：手机号、密码 * @param session session中获取验证码，保存用户到session * @return */ @Override public Result login(LoginFormDTO loginForm, HttpSession session) { // 校验手机号 String phone = loginForm.getPhone(); if (RegexUtils.isPhoneInvalid(phone)) { // 格式不符合，返回错误信息 Result.fail(\u0026#34;手机号格式错误！\u0026#34;); } // 校验验证码 Object cacheCode = session.getAttribute(phone); String code = loginForm.getCode(); // 如果没有发送验证码，或者session中验证码过期、验证码不一致 if (cacheCode == null || !cacheCode.toString().equals(code)) { // 不一致报错 return Result.fail(\u0026#34;验证码错误\u0026#34;); } // 一致，根据手机号查询用户 User user = query().eq(\u0026#34;phone\u0026#34;, phone).one(); // 判断用户是否存在 if (user == null) { // 不存在，创建新用户保存并返回 user = createUserWithPhone(phone); } // 保存用户到session中 session.setAttribute(\u0026#34;user\u0026#34;, BeanUtil.copyProperties(user, UserVo.class)); return Result.ok(); } /** * 根据手机号创建用户 * @param phone 手机号 * @return 用户 */ private User createUserWithPhone(String phone) { User user = new User(); user.setPhone(phone); user.setNickName(SystemConstants.USER_NICK_NAME_PREFIX + RandomUtil.randomString(10)); save(user); return user; } 4、实现登录拦截和登录验证功能\rTomcat运行原理 ![](Java/Java Web/Redis/files/12525ab041045c96f77fce057c444929_MD5.png)\n当用户发起请求时，会访问我们像tomcat注册的端口，任何程序想要运行，都需要有一个线程对当前端口号进行监听，当监听线程知道用户想要和tomcat连接连接时，那会由监听线程创建socket连接，socket都是成对出现的，用户通过socket像互相传递数据，当tomcat端的socket接受到数据后，此时监听线程会从tomcat的线程池中取出一个线程执行用户请求，在我们的服务部署到tomcat后，线程会找到用户想要访问的工程，然后用这个线程转发到工程中的controller，service，dao中，并且访问对应的DB，在用户执行完请求后，再统一返回，再找到tomcat端的socket，再将数据写回到用户端的socket，完成请求和响应。\n我们可以得知：每个用户其实对应都是去找tomcat线程池中的一个线程来完成工作的， 使用完成后再进行回收，既然每个请求都是独立的，所以在每个用户去访问我们的工程时，我们可以使用ThreadLocal来做到线程隔离，每个线程操作自己的一份数据。\n![](Java/Java Web/Redis/files/e09ef440ff613a3983b5370e7f72eaf9_MD5.png)\n当随着业务的开发，越来越多的业务都需要去校验用户的登录，我们应该考虑把用户登录验证功能的逻辑抽取到一个地方，就是SpringMVC的拦截器，它可以在请求所有Controller之前去做，用户的所有请求必须先经过拦截器，再由拦截器判断是否放行到对应的Controller。\n第二个问题是，拦截器帮我们完成了对用户的校验，拿到了用户信息，那对应的Controller如何拿到用户信息呢？因此我们应该设计一个方案，将拦截器里得到的用户信息传递到Controller，在传递过程中需要保证线程安全问题。这个方案就是将用户信息保存到ThreadLocal中。\n关于ThreadLocal ThreadLocal是一个线程域对象，每一个进入Tomcat的请求都是一个独立的线程，ThreadLocal会在当前用户线程内开辟一块独立的内存空间，保存信息到对应的ThreadLocalMap，保证每个线程互相不干扰。在ThreadLocal的源码中，无论是它的put方法还是get方法， 都是先从获得当前用户的线程，然后从线程中取出线程的成员变量map，只要线程不一样，map就不一样，所以可以通过这种方式来做到线程隔离。\n存入ThreadLocal中的主要原因\n（1）避免后续业务操作频繁向session域中存取数据，减少session的访问开销。 （2）避免线程安全问题。 登录验证功能页面请求\n![](Java/Java Web/Redis/files/d63b603c2de0b6bb4aa918ee10440de1_MD5.png)\n使用UserVo返回给前端，仅提供前端视图需要展示的属性，隐藏用户敏感信息 /** * 用户Vo */ @Data public class UserVo { // 用户id private Long id; // 昵称 private String nickName; // 头像 private String icon; } ThreadLocal工具类 /** * ThreadLocal工具类 */ public class BaseContext { /** ThreadLocal对象 */ private static final ThreadLocal threadLocal = new ThreadLocal(); /** * 根据键获取值 */ public static \u0026lt;T\u0026gt; T get() { return (T) threadLocal.get(); } /** * 存储键值对 */ public static \u0026lt;T\u0026gt; void set(T value) { threadLocal.set(value); } /** * 清除ThreadLocal，销毁线程，防止内存泄漏 */ public static void remove() { threadLocal.remove(); } } 拦截器代码 /** * 登录拦截器：需要实现 HandlerInterceptor 接口 */ @Slf4j public class LoginInterceptor implements HandlerInterceptor { /** * 前置拦截，在Controller执行之前 * 做登录验证校验 */ @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 获取session HttpSession session = request.getSession(); // 获取session中的用户 Object user = session.getAttribute(\u0026#34;user\u0026#34;); // 判断用户是否存在 if (user == null) { // 不存在，拦截，响应401状态码：未授权 response.setStatus(HttpStatus.HTTP_UNAUTHORIZED); return false; } // 存在，保存用户信息到ThreadLocal中 BaseContext.set((UserVo) user); log.debug(\u0026#34;拦截器BaseContext.UserVo = {}\u0026#34;, (UserVo) BaseContext.get()); // 放行 return true; } /** * 后置拦截，在Controller执行之后 */ @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { HandlerInterceptor.super.postHandle(request, response, handler, modelAndView); } /** * 在视图渲染之后，返回给用户之前 * 业务执行完后，销毁用户信息，避免内存泄露 */ @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { // 移除用户信息，销毁当前线程，防止内存泄露 BaseContext.remove(); } } 在SpringMVC配置类中注册拦截器，让拦截器生效 /** * SpringMVC配置类：需要实现 WebMvcConfigurer 接口 */ @Configuration public class MvcConfig implements WebMvcConfigurer { /** * 向拦截器注册器中添加拦截器，使拦截器生效 * @param registry 拦截器注册器 */ @Override public void addInterceptors(InterceptorRegistry registry) { // 本项目是前后端分离的，静态资源在nginx前端服务器中，因此访问静态资源的时候不会走到后端拦截器，所以不用放行前端静态资源 registry.addInterceptor(new LoginInterceptor()) // 排除不需要拦截的请求路径 .excludePathPatterns( \u0026#34;/user/code\u0026#34;, \u0026#34;/user/login\u0026#34;, \u0026#34;/blog/hot\u0026#34;, \u0026#34;/shop/**\u0026#34;, \u0026#34;/shop-type/**\u0026#34;, \u0026#34;/voucher/**\u0026#34;, \u0026#34;/upload/**\u0026#34; ); } } 实现登录验证功能 /** * 校验登录状态 */ @GetMapping(\u0026#34;/me\u0026#34;) public Result me(){ // 获取当前登录的用户并返回 UserVo userVo = BaseContext.get(); return Result.ok(userVo); } 实现登出功能 /** * 登出功能 */ @PostMapping(\u0026#34;/logout\u0026#34;) public Result logout(HttpSession session){ // 销毁ThreadLocal中的用户信息 BaseContext.remove(); // 销毁session session.invalidate(); return Result.ok(); } 5、集群的Session共享问题\rSession共享问题：当搭建多节点Tomcat服务器集群时，多台Tomcat并不共享session存储空间，当请求切换到不同tomcat服务时导致数据丢失的问题。\n核心问题分析：\n每个tomcat中都有一份属于自己的session，假设用户第一次访问第一台tomcat，并且把自己的信息存放到第一台服务器的session中，但是第二次这个用户访问到了第二台tomcat，那么在第二台服务器上，肯定没有第一台服务器存放的session，所以此时整个登录拦截功能就会出现问题。\n![](Java/Java Web/Redis/files/4431eaa6e7fb2d53bd8c3cf6755491e3_MD5.png)\n我们能如何解决这个问题呢？早期的方案是session拷贝，就是说虽然每个tomcat上都有不同的session，但是每当任意一台服务器的session修改时，都会同步给其他的tomcat服务器的session，这样的话，就可以实现session的共享了。\n但是这种方案具有两个大问题：\n每台服务器中都有完整的一份session数据，服务器压力过大，冗余备份问题。 session拷贝数据时，可能会出现延迟，数据一致性存在问题 此时适合解决这个场景的方案需要满足三点：\n数据共享 高并发下读写迅速 存储结构为key-value 由于redis是基于内存存储，性能非常强，读写延迟基本在微秒级别。因此我们将session换成redis存储数据，redis脱离了tomcat存储数据，支持多台tomcat服务器共享数据，也就避免了session共享的问题。为了保证redis数据不丢失，后期还可以考虑搭建redis集群。\n6、基于Redis实现共享Session登录流程梳理\r（1）设计存储的数据结构\r首先我们要思考一下利用redis来存储数据，那么到底使用哪种结构呢？由于存入的数据比较简单，我们可以考虑使用String 或者hash ，如下图。\n![](Java/Java Web/Redis/files/215e2cc223f813b4f75b3595dbf5d45a_MD5.png)\nString结构与Hash结构存储值的比较 String的值是将Java对象序列化为JSON字符串的形式保存，更加直观，操作也更加简单，但是 JSON 结构会有很多非必须的内存开销，比如双引号、大括号，内存占用比Hash更高。 Hash的值是以hash表的形式保存，通过field单独存储对象中每个属性的值，对单个字段增删改查更加灵活，hash类型存储空间占用比String类型更小。 这里我们选择使用hash结构来实现存储。\n（2）设计Key的细节\r关于key的处理，session是每个用户都有自己独立的session，因此key可以写死为code，因为每个session有不同的sessionId来保证唯一性。但是redis的key是共享的，就不能使用硬编码的key了。\n因此在设计key的适合，需要满足两点：\nkey要具有唯一性 key要方便客户端携带，方便从redis中取出这个value 如果我们采用phone手机号作为key来存储当然是可以的，但是如果把这样的敏感数据存储到redis中并且从页面中带过来毕竟不太合适。所以我们在后台生成一个随机串token，tomcat并不会像sessionId那样把token自动写到客户端浏览器中，因此我们需要手动的把token返回给客户端，客户端保存token作为登录凭证，之后客户端携带着token来发送请求，后端服务器验证token后就可以基于token从redis中获取数据。\n（3）整体访问流程\r当注册完成后，用户去登录会去校验用户提交的手机号和验证码是否一致，如果一致，则根据手机号查询用户信息，不存在则新建，最后将用户数据保存到redis，并且生成token作为redis的key，当我们校验用户是否登录时，会去携带着token进行访问，从redis中取出token对应的value，判断是否存在这个数据，如果没有则拦截，如果存在则将其保存到threadLocal中，并且放行。\n![](Java/Java Web/Redis/files/a3a9b9e6161e1c5d27d6c2fc552ca32a_MD5.png)\n前端处理token的代码：\n![](Java/Java Web/Redis/files/e629a854fdd4c9225d0ea986e49f1386_MD5.png)\n![](Java/Java Web/Redis/files/c41f058cc623f7677756b3988ea94b64_MD5.png)\n前端发送axios请求，被前端拦截器拦截，在请求头中填充token，经过后端拦截器，从请求头中的通过authorization获取token字符串，从而拿出redis中的用户数据。\n7、基于Redis实现短信登录\rUserServiceImpl /** * 登录、注册 * @param loginForm 验证码登录：手机号、验证码；密码登录：手机号、密码 * @return */ @Override public Result login(LoginFormDTO loginForm) { // 校验手机号 String phone = loginForm.getPhone(); if (RegexUtils.isPhoneInvalid(phone)) { // 格式不符合，返回错误信息 Result.fail(\u0026#34;手机号格式错误！\u0026#34;); } // 从redis中获取验证码并校验 String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY_PREFIX + phone); String code = loginForm.getCode(); // 如果没有发送验证码或者redis中验证码过期、验证码不一致 if (cacheCode == null || !cacheCode.equals(code)) { // 不一致报错 return Result.fail(\u0026#34;验证码错误\u0026#34;); } // 一致，根据手机号查询用户 User user = query().eq(\u0026#34;phone\u0026#34;, phone).one(); // 判断用户是否存在 if (user == null) { // 不存在，创建新用户保存并返回 user = createUserWithPhone(phone); } // 随机生成token，作为登录令牌，相当于JsessionId String token = UUID.fastUUID().toString(true); // fastUUID比randomUUID生成速度更快，toString(true)表示生成的UUID不带-短横线分隔 // 将UserVo对象转为HashMap存储 UserVo userVo = BeanUtil.copyProperties(user, UserVo.class); Map\u0026lt;String, Object\u0026gt; userMap = BeanUtil.beanToMap(userVo, new HashMap\u0026lt;\u0026gt;(), CopyOptions.create() // 忽略值为null的数据 .setIgnoreNullValue(true) // 参数：字段名和字段值，返回值是修改后的字段值。将字段值转为字符串 .setFieldValueEditor((fieldName, fieldValue) -\u0026gt; fieldValue.toString())); // 保存用户信息到redis中 String tokenKey = LOGIN_USER_KEY_PREFIX + token; stringRedisTemplate.opsForHash().putAll(tokenKey, userMap); // 设置用户信息的有效期 stringRedisTemplate.expire(tokenKey, Duration.ofMinutes(LOGIN_USER_TTL)); // 返回token return Result.ok(token); } LoginInterceptor /** * 登录拦截器：需要实现 HandlerInterceptor 接口 */ @Slf4j public class LoginInterceptor implements HandlerInterceptor { private StringRedisTemplate stringRedisTemplate; public LoginInterceptor(StringRedisTemplate stringRedisTemplate) { this.stringRedisTemplate = stringRedisTemplate; } /** * 前置拦截，在Controller执行之前 * 做登录验证校验 */ @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 获取请求头中的token String token = request.getHeader(\u0026#34;authorization\u0026#34;); // 判断用户是否是已登录状态 if (StrUtil.isBlank(token)) { // token为空说明未登录，拦截，响应401状态码：未授权 response.setStatus(HttpStatus.HTTP_UNAUTHORIZED); return false; } // 基于token获取redis中的用户信息（若entries的key为空默认返回空Map） String tokenKey = LOGIN_USER_KEY_PREFIX + token; Map\u0026lt;Object, Object\u0026gt; userMap = stringRedisTemplate.opsForHash().entries(tokenKey); // 判断用户是否存在 if (userMap.isEmpty()) { // 不存在，拦截，响应401状态码：未授权 response.setStatus(HttpStatus.HTTP_UNAUTHORIZED); return false; } // 将查询到的Hash数据转为UserVo对象 UserVo userVo = BeanUtil.fillBeanWithMap(userMap, new UserVo(), false); // 存在，保存用户信息到ThreadLocal中 BaseContext.set(userVo); //log.debug(\u0026#34;拦截器BaseContext.UserVo = {}\u0026#34;, (UserVo) BaseContext.get()); // 只要用户在操作发送请求，就刷新token有效期 stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES); // 放行 return true; } /** * 在视图渲染之后，返回给用户之前 * 业务执行完后，销毁用户信息，避免内存泄露 */ @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { // 移除用户信息，销毁当前线程，防止内存泄露 BaseContext.remove(); } } 8、解决登录状态刷新问题\r（1）初始方案问题分析\r在这个方案中，他确实可以使用对应路径的拦截，同时刷新登录token令牌的存活时间，但是现在这个拦截器他只是拦截需要被拦截的路径，假设当前用户访问了一些不需要拦截的路径，那么这个拦截器就不会生效，所以此时令牌刷新的动作实际上就不会执行，所以这个方案他是存在问题的。\n![](Java/Java Web/Redis/files/6accb191c23a08c6030f7b9d65ddc0ca_MD5.png)\n（2）优化登录拦截器\r单独配置一个拦截器用户刷新Redis中的token：在基于Session实现短信验证码登录时，我们只配置了一个拦截器，这里需要另外再配置一个拦截器专门用户刷新存入Redis中的token。因为我们现在改用Redis了，为了防止用户在操作网站时突然由于Redis中的token过期，导致直接退出网站，严重影响用户体验。\n那为什么不把刷新的操作放到一个拦截器中呢？因为之前的那个拦截器只是用来拦截一些需要进行登录校验的请求，对于哪些不需要登录校验的请求是不会走拦截器的，刷新操作显然是要针对所有请求比较合理，所以单独创建一个拦截器拦截一切请求，对于已登录的用户刷新Redis中的token，对于未登录的用户请求，不需要刷新Redis中的token，直接放行交给第二个登录拦截器去做未授权拦截，完成整体刷新功能。\n![](Java/Java Web/Redis/files/474d90301653aa5fb4de9a26d51a6888_MD5.png)\n因为第一个拦截器有了ThreadLocal的数据，所以此时第二个拦截器只需要判断ThreadLocal中的用户信息是否存在即可，存在说明已登录放行，不存在说明未登录进行未授权拦截。\n刷新token的拦截器 /** * 刷新Token拦截器：需要实现 HandlerInterceptor 接口 */ public class RefreshTokenInterceptor implements HandlerInterceptor { private StringRedisTemplate stringRedisTemplate; public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) { this.stringRedisTemplate = stringRedisTemplate; } /** * 拦截一切用户请求 * 对于已登录的用户刷新Redis中的token * 对于未登录的用户请求，不需要刷新Redis中的token，直接放行交给第二个登录拦截器去做未授权拦截 */ @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 获取请求头中的token String token = request.getHeader(\u0026#34;authorization\u0026#34;); // 判断用户是否是已登录状态 if (StrUtil.isBlank(token)) { // token不存在，说明当前用户未登录，不需要刷新直接放行 return true; } // 基于token获取redis中的用户信息（若entries的key为空默认返回空Map） String tokenKey = LOGIN_USER_KEY_PREFIX + token; Map\u0026lt;Object, Object\u0026gt; userMap = stringRedisTemplate.opsForHash().entries(tokenKey); // 判断用户是否存在 if (userMap.isEmpty()) { // 用户不存在，说明当前用户未登录，不需要刷新直接放行 return true; } // 将查询到的Hash数据转为UserVo对象 UserVo userVo = BeanUtil.fillBeanWithMap(userMap, new UserVo(), false); // 存在，保存用户信息到ThreadLocal中 BaseContext.set(userVo); // 只要用户在操作发送请求，就刷新token有效期 stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES); // 放行 return true; } /** * 在视图渲染之后，返回给用户之前 * 业务执行完后，销毁用户信息，避免内存泄露 */ @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { // 移除用户信息，销毁当前线程，防止内存泄露 BaseContext.remove(); } } 登录拦截器 /** * 登录拦截器：需要实现 HandlerInterceptor 接口 */ public class LoginInterceptor implements HandlerInterceptor { /** * 前置拦截，在Controller执行之前 * 做登录验证校验，只需要判断ThreadLocal中的用户信息是否存在即可，存在说明已登录放行，不存在说明未登录进行未授权拦截。 */ @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 判断是否需要拦截（ThreadLocal中是否有用户） if (BaseContext.get() == null) { // 没有用户信息，未登录，设置状态码401 response.setStatus(HttpStatus.HTTP_UNAUTHORIZED); // 拦截 return false; } // 有用户信息，已登录，放行 return true; } } 改进登出功能 /** * 登出功能 */ @PostMapping(\u0026#34;/logout\u0026#34;) public Result logout(HttpSession session, HttpServletRequest request) { // 销毁ThreadLocal中的用户信息 BaseContext.remove(); // 销毁session session.invalidate(); // 获取请求头中的token String tokenKey = RedisConstants.LOGIN_USER_KEY_PREFIX + request.getHeader(\u0026#34;authorization\u0026#34;); // 判断redis中的tokenKey是否存在 if (Boolean.TRUE.equals(stringRedisTemplate.hasKey(tokenKey))) { // 使redis中的token失效 stringRedisTemplate.delete(tokenKey); } return Result.ok(); } 三、商户查询缓存\r什么是缓存？ 缓存就是数据交换的缓冲区（称作Cache），是存贮数据的临时地方，一般读写性能较高。\n缓存数据存储于代码中，而代码运行在内存中，内存的读写性能远高于磁盘，缓存可以大大降低用户访问并发量带来的服务器读写压力。\n![](Java/Java Web/Redis/files/76ddc59b518323d1a0b746a02ec3fd9f_MD5.png)\n但是缓存也会增加代码复杂度和运营的成本：\n![](Java/Java Web/Redis/files/07c3b7becae8fb41fc2bc518461bc710_MD5.png)\n1、添加商户缓存\r当我们根据id查询商户信息时，我们是直接操作从数据库中去进行查询的，所以我们需要增加缓存，\n![](Java/Java Web/Redis/files/ddc5adffb0e3376f54229cb53aad7c3e_MD5.png)\n代码思路：如果缓存有，则直接返回，如果缓存不存在，则查询数据库，存入redis之后再返回。\n/** * 根据id查询商铺信息 * @param id 商铺id * @return 商铺详情数据 */ @Override public Result queryById(Long id) { // 从redis中查询店铺数据 String cacheKey = CACHE_SHOP_KEY_PREFIX + id; String shopJson = stringRedisTemplate.opsForValue().get(cacheKey); // 判断缓存是否命中 if (StrUtil.isNotBlank(shopJson)) { // 缓存命中，直接返回店铺数据 Shop shop = JSONUtil.toBean(shopJson, Shop.class); return Result.ok(shop); } // 缓存未命中，从数据库中查询店铺数据 Shop shop = getById(id); // 判断数据库是否存在店铺数据 if (shop == null) { // 数据库中不存在，返回失败信息 return Result.fail(\u0026#34;店铺不存在\u0026#34;); } // 数据库中存在，写入redis，并返回店铺数据 stringRedisTemplate.opsForValue().set(cacheKey, JSONUtil.toJsonStr(shop)); return Result.ok(shop); } 对于店铺详细这类变化较为频繁的数据，我们是直接存入Redis中，后面还会进行优化，设置合适的缓存更新策略，确保Redis和MySQL的数据一致性，以及解决缓存常见的三大问题。\n![](Java/Java Web/Redis/files/fb93262203932b34c4c09426a11da89e_MD5.png)\n2、查询店铺类型缓存\r对于店铺类型数据，一般变动会比较小，所以这里我们直接将店铺类型的数据持久化存储到Redis中\n使用String类型的key-value结构缓存店铺类型数据 ![](Java/Java Web/Redis/files/3ce91b4ae22a41354433051bf76cb66d_MD5.png)\nString存储写法 /** * 查询店铺类型列表 * @return 店铺类型列表 */ @Override public Result queryTypeList() { // 从redis中查询店铺类型数据 String shopTypeJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_TYPE_KEY); List\u0026lt;ShopType\u0026gt; shopTypeList = null; // 判断缓存是否命中 if (StrUtil.isNotBlank(shopTypeJson)) { // 缓存命中，直接返回缓存数据 shopTypeList = JSONUtil.toList(shopTypeJson, ShopType.class); return Result.ok(shopTypeList); } // 缓存未命中，查询数据库 shopTypeList = this.query().orderByAsc(\u0026#34;sort\u0026#34;).list(); // 判断数据库中是否存在该数据 if (shopTypeList == null) { // 数据库中不存在该数据，返回失败信息 return Result.fail(\u0026#34;店铺类型不存在\u0026#34;); } // 数据库中的数据存在，写入Redis，并返回查询的数据 stringRedisTemplate.opsForValue().set(CACHE_SHOP_TYPE_KEY, JSONUtil.toJsonStr(shopTypeList), CACHE_SHOP_TYPE_TTL, TimeUnit.MINUTES); // 将数据库查到的数据返回 return Result.ok(shopTypeList); } 使用List类型缓存店铺类型数据 ![](Java/Java Web/Redis/files/b3f2578a104e8b81b1c1c0f3daa5ac13_MD5.png)\nList存储写法 /** * 查询店铺类型列表 * @return 店铺类型列表 */ @Override public Result queryTypeList() { // 从redis中查询店铺类型数据 ListOperations\u0026lt;String, String\u0026gt; ops = stringRedisTemplate.opsForList(); List\u0026lt;ShopType\u0026gt; shopTypeList; // 0到-1表示查询List中所有元素 List\u0026lt;String\u0026gt; shopTypeJsonList = ops.range(CACHE_SHOP_TYPE_KEY, 0, -1); // 判断缓存是否命中 if (CollUtil.isNotEmpty(shopTypeJsonList)) { // 缓存命中，直接返回缓存数据 shopTypeList = shopTypeJsonList.stream() // 将 List\u0026lt;String\u0026gt; 转换为 List\u0026lt;ShopType\u0026gt; 返回 .map((shopTypeJson) -\u0026gt; JSONUtil.toBean(shopTypeJson, ShopType.class)) .collect(Collectors.toList()); return Result.ok(shopTypeList); } // 缓存未命中，查询数据库 shopTypeList = this.query().orderByAsc(\u0026#34;sort\u0026#34;).list(); // 判断数据库中是否存在该数据 if (shopTypeList == null) { // 数据库中不存在该数据，返回失败信息 return Result.fail(\u0026#34;店铺类型不存在\u0026#34;); } // 数据库中的数据存在，写入Redis，并返回查询的数据 ops.rightPushAll(CACHE_SHOP_TYPE_KEY, shopTypeList.stream().map(JSONUtil::toJsonStr).collect(Collectors.toList())); // 设置key的过期时间 stringRedisTemplate.expire(CACHE_SHOP_TYPE_KEY, CACHE_SHOP_TYPE_TTL, TimeUnit.MINUTES); // 将数据库查到的数据返回 return Result.ok(shopTypeList); } 由于在店铺类型ShopType中定义的createTime和updateTime是LocalDateTime类型。如果不自定义配置RedisConfig，那么日期类型存入Redis中会序列化为无意义的数字。\n// 创建时间 @JsonFormat(pattern=\u0026#34;yyyy-MM-dd HH:mm:ss\u0026#34;, timezone = \u0026#34;GMT+8\u0026#34;) private LocalDateTime createTime; // 更新时间 @JsonFormat(pattern=\u0026#34;yyyy-MM-dd HH:mm:ss\u0026#34;, timezone = \u0026#34;GMT+8\u0026#34;) private LocalDateTime updateTime; 因此我们可以写一个RedisConfig配置类，在里面配置序列化器与反序列化器，这次value和hashValue使用配置好的json对象映射器。下面给出Redis序列化器配置模板：\n@Configuration public class RedisConfig { @Bean public RedisTemplate\u0026lt;String, Object\u0026gt; redisTemplate(RedisConnectionFactory connectionFactory) { // 创建RedisTemplate\u0026lt;String, Object\u0026gt;对象 RedisTemplate\u0026lt;String, Object\u0026gt; template = new RedisTemplate\u0026lt;\u0026gt;(); // 设置redis连接工厂 template.setConnectionFactory(connectionFactory); // 使用StringRedisSerializer来序列化和反序列化Redis的key值 StringRedisSerializer stringRedisSerializer = new StringRedisSerializer(); // 配置对象映射器 Jackson2JsonRedisSerializer\u0026lt;Object\u0026gt; jsonRedisSerializer = new Jackson2JsonRedisSerializer\u0026lt;\u0026gt;(Object.class); // 序列化时将类的数据类型存入json，以便反序列化的时候转换成正确的类型 ObjectMapper mapper = new ObjectMapper(); // 指定要序列化的域，field，get和set，以及修饰符范围。ANY指包括private和public修饰符范围 mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); // 指定序列化输入类型，类的信息也将添加到json中，这样才可以根据类名反序列化。没有这行，将存储为纯json字符串 mapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.WRAPPER_ARRAY); // 解决jackson2无法反序列化LocalDateTime的问题 mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); mapper.registerModule(new JavaTimeModule()); //mapper.registerModule(new Jdk8Module()).registerModule(new JavaTimeModule()).registerModule(new ParameterNamesModule()); // 将对象映射器添加到序列化器中 jsonRedisSerializer.setObjectMapper(mapper); // 设置Key的序列化，使用String类型的序列化工具 StringRedisSerializer template.setKeySerializer(stringRedisSerializer); template.setHashKeySerializer(stringRedisSerializer); // 设置Value的序列化，使用JSON类型的序列化工具 Jackson2JsonRedisSerializer template.setValueSerializer(jsonRedisSerializer); template.setHashValueSerializer(jsonRedisSerializer); template.afterPropertiesSet(); return template; } } 改用RedisTemplate来缓存店铺类型 @Resource private RedisTemplate redisTemplate; @Override public Result queryTypeList() { // 从redis中查询店铺类型数据 ListOperations ops = redisTemplate.opsForList(); // 由于配置了序列化和反序列化器，存入Java对象，取出时也为Java对象 List\u0026lt;ShopType\u0026gt; shopTypeList = ops.range(CACHE_SHOP_TYPE_KEY, 0, -1); // 判断缓存是否命中 if (CollUtil.isNotEmpty(shopTypeList)) { // 缓存命中，直接返回缓存数据 return Result.ok(shopTypeList); } // 缓存未命中，查询数据库 shopTypeList = this.query().orderByAsc(\u0026#34;sort\u0026#34;).list(); // 判断数据库中是否存在该数据 if (shopTypeList == null) { // 数据库中不存在该数据，返回失败信息 return Result.fail(\u0026#34;店铺类型不存在\u0026#34;); } // 数据库中的数据存在，写入Redis，并返回查询的数据 ops.rightPushAll(CACHE_SHOP_TYPE_KEY, shopTypeList); // 直接将Java对象存入redis，不需要转为Json字符串再存储 // 设置key的过期时间 stringRedisTemplate.expire(CACHE_SHOP_TYPE_KEY, CACHE_SHOP_TYPE_TTL, TimeUnit.MINUTES); // 将数据库查到的数据返回 return Result.ok(shopTypeList); } 这样可以简化代码的编写，但会在value中存入反序列化时需要的全类名，虽然存储空间增加了，但也解决了日期类LocalDateTime序列化和反序列化时格式不正确的问题。\n![](Java/Java Web/Redis/files/37b2f529a8aff8792536234a595c5eef_MD5.png)\n配置RedisConfig解决日期类序列化和反序列化问题可以一劳永逸，如果不配置序列化器，还可以在日期属性上添加这三个注解。（但是需要每个LocalDateTime日期属性上都添加，可维护性差）\n// 格式化LocalDateTime在Json中的日期格式 @JsonFormat(pattern=\u0026#34;yyyy-MM-dd HH:mm:ss\u0026#34;, timezone = \u0026#34;GMT+8\u0026#34;) // @JsonDeserialize：json反序列化注解，用于字段或set方法上，作用于setter()方法，将json数据反序列化为java对象 @JsonDeserialize(using = LocalDateTimeDeserializer.class) // @JsonSerialize：json序列化注解，用于字段或set方法上，作用于getter()方法，将java对象序列化为json数据 @JsonSerialize(using = LocalDateTimeSerializer.class) private LocalDateTime updateTime; 3、缓存一致性问题\r使用缓存的好处：降低了后端负载，提高了读写的效率，降低了响应的时间。\n缓存带来的问题：缓存的添加提高了系统的维护成本，同时也带来了数据一致性问题。\n由于我们的缓存的数据源来自于数据库，而数据库的数据是会发生变化的，因此，如果当数据库中数据发生变化，而缓存却没有同步 ，此时就存在 缓存数据一致性问题 。\n缓存数据一致性问题的根本原因是 缓存和数据库中的数据不同步 。\n那么我们该如何让 缓存 和 数据库中的数据尽可能的保证同步？首先需要选择一个比较好的缓存更新策略。\n（1）常见的缓存更新策略\r内存淘汰（自动）： 利用 Redis的内存淘汰机制 实现缓存更新，Redis的内存淘汰机制是当Redis发现内存不足时，会根据一定的策略自动淘汰部分数据。\nRedis中常见的淘汰策略：\nnoeviction（默认）：当达到内存限制并且客户端尝试执行写入操作时，Redis 会返回错误信息，拒绝新数据的写入，保证数据完整性和一致性 allkeys-lru：从所有的键中选择最近最少使用（Least Recently Used，LRU）的数据进行淘汰。即优先淘汰最长时间未被访问的数据 allkeys-random：从所有的键中随机选择数据进行淘汰 volatile-lru：从设置了过期时间的键中选择最近最少使用的数据进行淘汰 volatile-random：从设置了过期时间的键中随机选择数据进行淘汰 volatile-ttl：从设置了过期时间的键中选择剩余生存时间（Time To Live，TTL）最短的数据进行淘汰 **超时剔除（半自动）：**手动给缓存数据设置过期时间TTL，到期后Redis自动删除超时的数据。\n**主动更新（手动）：**手动编码实现缓存更新，在修改数据库的同时更新缓存。我们可以手动调用方法把缓存删掉，通常用于解决缓存和数据库不一致问题。\n![](Java/Java Web/Redis/files/156fafb797ebfccd584aa2a7393b355b_MD5.png)\n缓存更新策略的选择，应该看业务场景对数据一致性的的需求：\n低一致性需求：使用Redis自带的内存淘汰机制 + 超时更新。 高一致性需求：主动更新，并以超时剔除作为兜底方案。 （2）主动更新策略的三种方案\r双写方案（Cache Aside Pattern）：人工编码方式，缓存调用者在更新完数据库后再去更新缓存。维护成本高，灵活度高。 读写穿透方案（Read/Write Through Pattern）：将数据库和缓存整合为一个服务，由服务来维护缓存与数据库的一致性，调用者无需关心数据一致性问题，降低了系统的可维护性，但是实现困难，也没有较好的第三方服务供我们使用。 写回方案（Write Behind Caching Pattern）：调用者只操作缓存，其他独立的线程去异步处理数据库，将待写入的数据放入一个缓存队列，在适当的时机，通过批量操作或异步处理，将缓存队列中的数据持久化到数据库，实现最终一致。 ![](Java/Java Web/Redis/files/582cdaf80ee682ad664e1ff4914ababe_MD5.png)\n双写方案 和 读写穿透方案 在写入数据时都会直接更新缓存，以保持缓存和底层数据存储的一致性。\n写回方案 延迟了缓存的更新操作，又由于异步更新机制，将多次对数据库的写合并成一次写，将多次对数据库的更新以最后一次更新的结果作为有效数据，去更新数据库。\n主动更新策略中三种方案的应用场景 ：\n双写方案 较适用于读多写少的场景，数据的一致性由应用程序主动管理 读写穿透方案 适用于数据实时性要求较高、对一致性要求严格的场景 写回方案 适用于追求写入性能的场景，对数据的实时性要求相对较低、可靠性也相对低，延迟写入的数据是在内存中的。 综合考虑使用方案一，虽然双写方案需要缓存调用者手动编码维护，但可控性更高。\n（3）双写方案 操作缓存和数据库需要考虑的三个问题\r使用双写方案操作缓存和数据库时有三个问题需要考虑：\n是删除缓存还是更新缓存？（两种缓存更新方案，选择效率更高的） 更新缓存：每次更新数据库都更新缓存，无效写操作较多（不推荐） 如果采用更新缓存，假如我们执行100次更新数据库操作，那么就要执行100次写入缓存的操作，而在这期间并没有查询请求，也就是写多读少，那么这100次写入缓存的操作就是无效的写操作。\n删除缓存：更新数据库时让缓存失效，查询时再更新缓存（推荐） 如果采用删除缓存，假设更新100次，只需要删一次缓存，在这期间没有被访问，则不会去更新缓存，等到数据库被查询了再去写入缓存，相当于延迟加载模式，这种写缓存的频率更低，有效更新会更多。\n如何保证缓存与数据库的操作的同时成功或失败（原子性）？ 单体系统，将缓存与数据库操作放在一个 事务 中 分布式系统，利用TCC（Try-Confirm-Cancel）等 分布式事务 方案 先操作缓存 还是 先操作数据库 ？（多线程环境需要考虑） 先删除缓存，再操作数据库（线程安全问题发生概率较高） 如果选择第一种方案，在两个线程并发来访问时，线程1先来，先把缓存删了，假设数据库更新的业务比较复杂耗时较久，由于没有加锁，此时线程2过来，他查询缓存数据不存在，此时他查询数据库查到的是数据库未更新完成的旧数据，当他写入旧数据到缓存后，线程1继续将新数据更新到数据库，这样就出现了多线程环境下的数据库与缓存不一致的问题。\n当线程1删除缓存到更新数据库之间的时间段，会有其它线程进来查询数据，且线程1将缓存删除了，这就导致请求会直接打到数据库上，给数据库带来巨大压力，还可能造成缓存击穿。这个事件发生的概率很大，因为数据库的读写速度慢，而缓存的读写速度块。\n先操作数据库，再删除缓存（线程安全问题发生概率较低） 当线程1在查询缓存，此时缓存恰好失效，缓存未命中，此时线程1查询数据库数据，查询完正准备写入缓存时，由于没有加锁线程2抢占到CPU执行权，线程2在这期间对数据库进行了更新，接着删除缓存（此时缓存为空相当于没变），线程2结束后线程1接着写缓存，但是线程1写入缓存的是之前查数据库的旧数据。\n这个事件发生的概率很低，因为先是需要满足 线程在并行执行 查询缓存时恰好失效未命中，且在写入缓存（微秒级别）的那段时间内有一个线程抢占执行更新操作，缓存的查询很快，这段空隙时间很小，在这期间完成耗时的写操作，可能性不大。\n![](Java/Java Web/Redis/files/f7e0cc72433be170d75533d4ae0661b1_MD5.png)\n因此，我们选择 先操作数据库，再删除缓存 。\n读操作：缓存命中则直接返回，未命中则查询数据库，并写入缓存，设置超时时间。\n写操作：先写数据库，再删除缓存，要确保数据库和缓存的原子性。\n4、实现商铺查询的数据库与缓存双写一致\r需求：修改ShopController，给查询商铺的缓存添加超时剔除和主动更新的策略\n根据id查询店铺时，如果缓存未命中，则查询数据库，将数据库结果写入缓存，并设置超时时间。 /** * 根据id查询商铺信息 * @param id 商铺id * @return 商铺详情数据 */ @Override public Result queryById(Long id) { // 从redis中查询店铺数据 String cacheKey = CACHE_SHOP_KEY_PREFIX + id; String shopJson = stringRedisTemplate.opsForValue().get(cacheKey); // 判断缓存是否命中 if (StrUtil.isNotBlank(shopJson)) { // 缓存命中，直接返回店铺数据 Shop shop = JSONUtil.toBean(shopJson, Shop.class); return Result.ok(shop); } // 缓存未命中，从数据库中查询店铺数据 Shop shop = getById(id); // 判断数据库是否存在店铺数据 if (shop == null) { // 数据库中不存在，返回失败信息 return Result.fail(\u0026#34;店铺不存在\u0026#34;); } // 数据库中存在，重建缓存，并返回店铺数据 stringRedisTemplate.opsForValue().set(cacheKey, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES); return Result.ok(shop); } 根据id修改店铺时，先修改数据库，再删除缓存。 /** * 更新商铺信息（写操作，先更新数据库，再删除缓存） * @param shop 商铺数据 * @return */ @Override @Transactional public Result updateShopById(Shop shop) { Long id = shop.getId(); if (id == null) { return Result.fail(\u0026#34;店铺id不能为空\u0026#34;); } // 更新数据库 updateById(shop); // 删除缓存 stringRedisTemplate.delete(CACHE_SHOP_KEY_PREFIX + id); return Result.ok(); } 代码分析：通过之前的淘汰，我们确定了采用删除策略，来解决双写问题，当我们修改了数据之后，把缓存中的数据进行删除，查询时发现缓存中没有数据，则会从mysql中查询数据并重新写入缓存，从而避免数据库和缓存不一致的问题。\n5、缓存穿透的解决方案\r缓存穿透：缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在，这样缓存永远不会生效，如果不断发起这样的请求，这些请求都会打到数据库，给数据库带来巨大压力。\n常见的解决方案有两种：\n缓存空对象 优点：实现简单，维护方便 缺点：额外的内存消耗、可能造成短期的不一致 布隆过滤 优点：内存占用较少，没有多余key 缺点：实现复杂、存在误判可能（有穿透的风险）、无法删除数据 ![](Java/Java Web/Redis/files/5263635cf2c8b7e09bc3c18611524e17_MD5.png)\n缓存空对象思路分析： 当我们客户端访问不存在的数据时，先请求redis，但是此时redis中没有数据，此时会访问到数据库，但是数据库中也没有数据，这个数据穿透了缓存，直击数据库。数据库能够承载的并发不如redis这么高，如果大量的请求同时过来访问这种不存在的数据，这些请求就都会访问到数据库，简单的解决方案就是哪怕这个数据在数据库中也不存在，我们也把这个数据存入到redis中去，这样，下次用户过来访问这个不存在的数据，那么在redis中也能找到这个数据就不会进入到缓存了。\n布隆过滤： 布隆过滤器其实采用的是哈希思想来解决这个问题，通过一个庞大的二进制数组，走哈希思想去判断当前这个要查询的这个数据是否存在，如果布隆过滤器判断存在，则放行，这个请求会去访问redis，哪怕此时redis中的数据过期了，但是数据库中一定存在这个数据，在数据库中查询出来这个数据后，再将其放入到redis中，假设布隆过滤器判断这个数据不存在，则直接返回。这种方式优点在于节约内存空间，存在误判，误判原因在于：布隆过滤器走的是哈希思想，只要哈希思想，就可能存在哈希冲突。\n这里使用方案一（缓存空对象）解决缓存穿透问题：\n![](Java/Java Web/Redis/files/fa834e2e6753277c7ba9889a64f4f916_MD5.png)\n核心思路如下：\n在原来的逻辑中，我们如果发现这个数据在MySQL中不存在，直接就返回404了，这样是会存在缓存穿透问题的。 现在的逻辑中：如果这个数据不存在，我们不会返回404 ，还是会把这个数据写入到Redis中，只不过设置写入的value为空值。当再次发起查询时，我们如果发现命中之后，判断这个value是否是空值，如果是空值，则是缓存穿透数据，如果不是，则直接返回数据。 /** * 根据id查询商铺信息 * @param id 商铺id * @return 商铺详情数据 */ @Override public Result queryById(Long id) { // 从redis中查询店铺数据 String cacheKey = CACHE_SHOP_KEY_PREFIX + id; String shopJson = stringRedisTemplate.opsForValue().get(cacheKey); // 判断缓存是否命中 if (StrUtil.isNotBlank(shopJson)) { // 缓存命中，直接返回店铺数据 Shop shop = JSONUtil.toBean(shopJson, Shop.class); return Result.ok(shop); } // 防止缓存穿透：缓存未命中，判断缓存中命中的是否是空值（isNotBlank把null和空字符串给排除了） if (\u0026#34;\u0026#34;.equals(shopJson)) { // 当前数据是空字符串（说明该数据是之前缓存的空对象），返回失败信息 return Result.fail(\u0026#34;店铺不存在\u0026#34;); } // 缓存未命中，从数据库中查询店铺数据 Shop shop = getById(id); // 判断数据库是否存在店铺数据 if (shop == null) { // 数据库中不存在，将空值写入redis stringRedisTemplate.opsForValue().set(cacheKey, \u0026#34;\u0026#34;, CACHE_NULL_TTL, TimeUnit.MINUTES); // 返回失败信息 return Result.fail(\u0026#34;店铺不存在\u0026#34;); } // 数据库中存在，重建缓存，并返回店铺数据 stringRedisTemplate.opsForValue().set(cacheKey, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES); return Result.ok(shop); } 第一次查询数据库中和缓存中不存在的数据，请求经过了数据库，并缓存了空字符串，设置了短期TTL。\n![](Java/Java Web/Redis/files/c68e28e86cd94e3f032a47270bba6cf5_MD5.png)\n第二次查询（TTL短期内），发起请求相同，查询不存在的数据，未经过数据库，提前判空值返回失败信息。\n![](Java/Java Web/Redis/files/2c1249e44b87b3c1476f28d495753758_MD5.png)\n总结：缓存穿透的解决方案有哪些？\n缓存空对象（被动） 布隆过滤（被动） 增强id的复杂度，避免被猜测id规律（主动） 做好数据的基础格式校验（主动） 加强用户权限校验（主动） 做好热点参数的限流（主动） 6、缓存雪崩的解决方案\r缓存雪崩：缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机（同一时间，缓存大面积过期失效），导致大量请求到达数据库，带来巨大压力。\n![](Java/Java Web/Redis/files/10dc7ccbe407a5e7917e882f6c4a8821_MD5.png)\n缓存雪崩常见解决方案：\n给不同的Key的TTL添加随机值（让失效时间离散分布，确保Key不会在同一时间大量失效） 利用Redis集群提高服务的可用性（主从集群、哨兵机制） 给缓存业务添加降级限流策略（比如快速失败机制，让请求尽可能打不到数据库上） 给业务添加多级缓存（浏览器缓存 -\u0026gt; Nginx反向代理缓存 -\u0026gt; Redis缓存 -\u0026gt; JVM本地缓存…） ![](Java/Java Web/Redis/files/0268f6766450a6f1b6c35d08c3805785_MD5.png)\n概念补充：\n缓存预热：缓存预热是指在系统启动之前或系统达到高峰期之前，将常用数据预先加载到缓存中，以提高缓存命中率和系统性能的过程。缓存预热的目的是模拟爆发式的请求，尽可能地避免缓存击穿和缓存雪崩，还可以减轻后端存储系统的负载，提高系统的响应速度和吞吐量。 哨兵模式：在集群模式下，监控Redis各个节点是否正常，如果主节点故障通过发布订阅模式通知其他节点，并进行故障转移，将其他正常的从节点指定为主节点。 7、缓存击穿的解决方案\r缓存击穿：缓存击穿问题也叫热点Key问题，就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了，无数的请求访问会在瞬间给数据库带来巨大的冲击。\n逻辑分析：假设线程1在查询缓存之后，本来应该去查询数据库，然后把这个数据重新加载到缓存的，此时只要线程1走完这个逻辑，其他线程就都能从缓存中加载这些数据了。但是假设查询数据库重建缓存的过程耗时较长，在线程1没有走完的时候，后续的线程2，线程3，线程4同时过来访问当前这个方法，那么这些线程就会同一时刻来查询缓存，都未命中，接着同一时间去查询数据库，重复进行缓存重建，导致数据库访问压力过大，这就是高并发访问的热点key失效造成缓存击穿。\n![](Java/Java Web/Redis/files/fca84d0f3086abc5bfe51df9e0c3a80d_MD5.png)\n缓存击穿与缓存雪崩有一定的区别，缓存雪崩是指许多key同时过期，导致大量数据查询失败，从而造成数据库负载激增。而缓存击穿则是由于高并发查同一条数据而导致数据库压力瞬间增大。\n缓存击穿的常见解决方案：\n互斥锁（确保一致性、牺牲服务可用性） 优点：没有额外的内存消耗，保证一致性，实现简单。 缺点：线程需要等待、性能较低，可能有死锁风险。 ![](Java/Java Web/Redis/files/d5ef1170fa8b103a2ff414cd7dff6ef0_MD5.png)\n方案分析：因为锁能实现互斥性。假设并发的线程过来，只能允许单个线程去访问数据库，从而避免对于数据库访问压力过大，但这也会影响业务的性能，因为此时会让业务从并行变成了串行，我们可以采用 tryLock方法 + double check 来解决这样的问题。\n假设现在线程1过来访问，它查询缓存没有命中，但是此时它获得了锁的资源，那么线程1就会单独去执行查询数据库重建缓存的逻辑。假设现在线程2过来，并没有获得到锁，那么线程2就可以先休眠一段时间再去重试查询缓存，直到线程1把锁释放后，线程2再查询缓存，此时就能命中缓存拿到数据了。\n逻辑过期 （确保服务可用性，牺牲一致性） 优点：线程无需等待，性能较好，不会影响并发能力。 缺点：不保证一致性、有额外内存消耗，实现复杂。 ![](Java/Java Web/Redis/files/4d2df69e77d4af5b1b7234e91f9d4244_MD5.png)\n方案分析：我们之所以会出现这个缓存击穿问题，主要原因是在于我们对key设置了过期时间，假设我们不设置过期时间，其实就不会有缓存击穿的问题，但是不设置过期时间，这样数据不就一直占用我们内存了吗，我们可以采用逻辑过期方案，配合redis的缓存淘汰策略，去避免高并发时期的缓存击穿。\n我们把过期时间expire设置在redis的value中，注意这个过期时间并不会直接作用于redis，而是我们后续通过逻辑去判断处理。假设线程1去查询缓存，然后从value中判断出来当前的数据已经过期了，此时线程1去获得互斥锁，那么其他线程会进行阻塞，获得了锁的线程会开启一个异步新线程去进行查询数据库重建缓存的逻辑，直到新开的线程2完成这个逻辑后，才释放锁，而线程1直接返回过期旧数据。假设现在线程3过来访问，由于异步线程2持有着锁，所以线程3无法获得锁，线程3也直接返回过期数据，只有等到新开的线程2把重建数据构建完后，其他线程才能命中缓存，返回没有过期的新数据。\n这种方案巧妙在于，异步的构建缓存，缺点在于在构建完缓存之前，返回的都是脏数据。\n![](Java/Java Web/Redis/files/d7a4a7dbfd134b66fedd9a58a4e1ceed_MD5.png)\n互斥锁形式牺牲了可用性保障了一致性 逻辑过期形式牺牲了一致性保障了可用性 两者相比较：\n互斥锁更加易于实现，但是加锁会导致这些并发的线程 并行 变成 串行 ，导致系统性能下降，还可能 发生不同业务之间的死锁。 逻辑过期实现起来相较复杂，因为需要额外维护一个逻辑过期时间，有额外的内存开销，但是通过异步开启子线程重建缓存，使原来的同步阻塞变成异步，提高系统的响应速度，在重建缓存这段时间内，其他线程来查询缓存发现缓存已过期，会直接返回过期数据。 8、利用互斥锁解决缓存击穿问题\r需求：修改根据id查询商铺的业务，基于互斥锁方式来解决缓存击穿问题。\n![](Java/Java Web/Redis/files/7f0dbcb48e10002661f7107e99dc78b4_MD5.png)\n核心思路：相较于原来从缓存中查询不到数据后直接查询数据库而言，现在的方案是查询Redis之后，如果从缓存没有查询到数据，则尝试获取互斥锁，如果没有获得到锁，则休眠一段时间，过一会儿再进行重试。如果未命中且获取到锁，说明是第一个拿到锁的线程，查询数据库，将数据写入Redis后再释放锁，最后返回数据。休眠结束后的线程重新查询Redis命中后直接返回数据，无需查询数据库。\n需要注意的是：这个互斥锁不能使用synchronized和Lock这样的本地单机锁，如果是分布式服务集群就会锁不住，如果要解决这个问题，需要使用分布式锁（后面会具体讲）。并且这两个单机锁加锁了只会阻塞等待，无法进行后面的休眠重试逻辑。互斥锁要求当有一个线程能获取到锁，其他线程都获取失败，因此这里可以使用Redis中String类型的setnx操作实现互斥锁。\n![](Java/Java Web/Redis/files/b9d6e0a5d21048cb1f6a144c163d122e_MD5.png)\n提示：setnx的功能是如果不存在这个key，则可以set，如果存在了这个key，则无法set。\n在StringRedisTemplate中一般使用setIfAbsent()方法，相当于setnx命令，并且在setIfAbsent()中设置有效时间，底层是set key value [EX seconds] [PX milliseconds] [NX|XX]，保证命令的原子性。\n使用setnx命令模拟加锁，即使是分布式服务的多线程环境下，由于Redis是单线程的，也只会允许一个线程去执行setnx加锁操作，所以不用担心多个线程同时setnx。而且在设置完锁后为了防止意外情况不释放锁，一般我们还会在setnx的时候加一个TTL有效期，避免锁无法释放产生死锁。这就是利用互斥锁保证只有一个线程去执行操作数据库，防止高并发环境下多个线程同时访问失效热点key造成缓存击穿。\n使用互斥锁改造queryById()方法，解决缓存击穿 /** * 根据id查询商铺信息 * @param id 商铺id * @return 商铺详情数据 */ @Override public Result queryById(Long id) { // 用互斥锁解决缓存击穿、同时解决缓存穿透 return queryWithMutex(id); } /** * 根据id查询商铺信息（用互斥锁解决缓存击穿、同时解决缓存穿透） * @param id 商铺id * @return 查询结果 */ private Result queryWithMutex(Long id) { // 从redis中查询店铺数据 String cacheKey = CACHE_SHOP_KEY_PREFIX + id; Shop shopFromCache = getShopFromCache(cacheKey); // 判断缓存是否命中 if (shopFromCache != null) { // 命中，直接返回 return Result.ok(shopFromCache); } // 实现重建缓存 String lockKey = LOCK_SHOP_KEY_PREFIX + id; Shop shop = null; try { // 尝试获取锁，判断是否获取锁成功 if (!tryLock(lockKey)) { // 获取失败，则休眠并重试 Thread.sleep(50L);\t// 休眠50毫秒 return queryWithMutex(id); } // 获取锁成功，再次检测redis中缓存是否存在（DoubleCheck），如果存在则无需重建缓存，防止堆积的线程全部请求数据库 shopFromCache = getShopFromCache(cacheKey); // 判断缓存是否命中 if (shopFromCache != null) { // 命中，直接返回 return Result.ok(shopFromCache); } // 缓存未命中，从数据库中查询店铺数据 shop = getById(id); // 判断数据库是否存在店铺数据 if (shop == null) { // 数据库中不存在，将空值写入redis，解决缓存穿透 stringRedisTemplate.opsForValue().set(cacheKey, \u0026#34;\u0026#34;, CACHE_NULL_TTL, TimeUnit.MINUTES); // 返回错误信息 return Result.fail(\u0026#34;店铺不存在\u0026#34;); } // 数据库中存在，重建缓存，并返回店铺数据 stringRedisTemplate.opsForValue().set(cacheKey, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES); } catch (InterruptedException e) { throw new RuntimeException(e); } finally { // 释放互斥锁 unLock(lockKey); } return Result.ok(shop); } /** * 从缓存中查询店铺数据 * @param cacheKey 商铺缓存key * @return 商铺详情数据 */ private Shop getShopFromCache(String cacheKey) { // 从redis中查询店铺数据 String shopJson = stringRedisTemplate.opsForValue().get(cacheKey); // 判断缓存是否命中 if (StrUtil.isNotBlank(shopJson)) { // 缓存命中，直接返回店铺数据 return JSONUtil.toBean(shopJson, Shop.class); } // 防止缓存穿透：判断缓存中命中的是否是空值（isNotBlank把null和空字符串给排除了） if (\u0026#34;\u0026#34;.equals(shopJson)) { // 当前数据是空字符串（说明该数据是之前缓存的空对象），缓存命中空对象，返回null return null; } // 缓存未命中，返回null return null; } /** * 尝试获取锁，判断是否获取锁成功 * setIfAbsent()：如果缺失不存在这个key，则可以set，返回true；存在key不能set，返回false。相当于setnx命令 * @param lockKey 互斥锁的key * @return 是否获取到锁 */ private boolean tryLock(String lockKey) { // 原子命令：set lock value ex 10 nx Boolean isGetLock = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, \u0026#34;1\u0026#34;, LOCK_SHOP_TTL, TimeUnit.SECONDS); // 为了避免Boolean直接返回自动拆箱未null，用工具包将null和false都返回为false return BooleanUtil.isTrue(isGetLock); } /** * 释放互斥锁 * @param lockKey 互斥锁的key */ private void unLock(String lockKey) { if (BooleanUtil.isTrue(stringRedisTemplate.hasKey(lockKey))) { stringRedisTemplate.delete(lockKey); } } 使用JMeter模拟多机同时请求，设置5秒内发送1000个请求，预计QPS将达到200左右。\n![](Java/Java Web/Redis/files/00c9540fe085caed9479df89caf0d995_MD5.png)\n配置请求接口参数\n![](Java/Java Web/Redis/files/9f9715be108845fc8005a8f2cb7b3b84_MD5.png)\n绿色表示线程成功执行\n![](Java/Java Web/Redis/files/37428e55e18fc44c71a8bf1201066ac6_MD5.png)\n压测结果显示，吞吐量为203.0/sec，由于这个请求业务中不涉及事务，即多个业务表的操作，所以这里的吞吐量Throughput可以理解为QPS，也就是每秒钟发送200个请求，每秒钟处理203个请求。\n![](Java/Java Web/Redis/files/109d8c2444a266989eb0377a500dfab8_MD5.png)\nTPS（Transactions Per Second）：每秒传输的事物处理个数。即服务器每秒处理的事务数。\nQPS（Queries Per Second）：每秒查询率。即服务器每秒能够处理的查询请求次数。\n那么我们对于一个页面做一次访问，就会形成一个TPS；但一次页面访问，可能产生多次对服务器的请求，服务器对这些请求，计为QPS。\n9、利用逻辑过期解决缓存击穿问题\r需求：修改根据id查询商铺的业务，基于逻辑过期方式来解决缓存击穿问题。\n![](Java/Java Web/Redis/files/a73115dd08672f011cafbefe2ef57159_MD5.png)\n思路分析：当用户开始查询redis时，判断是否命中，如果没有命中则直接返回空数据（说明不是热点key），不查询数据库。而一旦命中后，将value取出，判断value中的逻辑过期时间是否过期，如果未过期，直接返回redis中的新数据，如果过期，则在开启独立线程后直接返回之前的数据，独立线程异步去重建缓存，重建完成后释放互斥锁。\n使用逻辑过期改造queryById()方法，解决缓存击穿 /** * 缓存重建线程池 */ private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10); /** * 根据id查询商铺信息 * @param id 商铺id * @return 商铺详情数据 */ @Override public Result queryById(Long id) { // 用逻辑过期解决缓存击穿 return queryWithLogicalExpire(id); } /** * 使用逻辑过期解决缓存击穿 * 对于热点业务，提前预热缓存数据，设置永不自动过期，默认缓存一定被命中，不用考虑缓存穿透问题 * @param id 商铺id * @return 商铺详情数据 */ private Result queryWithLogicalExpire(Long id) { // 从缓存中获取热点数据 String cacheKey = CACHE_SHOP_KEY_PREFIX + id; String shopJson = stringRedisTemplate.opsForValue().get(cacheKey); // 判断缓存是否命中（由于是热点数据，提前进行缓存预热，默认缓存一定会命中） if (StrUtil.isBlank(shopJson)) { // 缓存未命中，说明查到的不是热点key，直接返回空 return Result.fail(\u0026#34;店铺不存在（非热点数据）\u0026#34;); } // 缓存命中，先把json反序列化为逻辑过期对象 RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class); // RedisData\u0026lt;Shop\u0026gt;使用了泛型处理：JSONUtil.toBean(shopJson, new TypeReference\u0026lt;RedisData\u0026lt;Shop\u0026gt;\u0026gt;() {}, false); // 将Object对象转成JSONObject再反序列化为目标对象 Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class); // 判断是否逻辑过期 if (redisData.getExpireTime().isAfter(LocalDateTime.now())) { // 未过期，直接返回正确数据 return Result.ok(shop); } // 已过期，先尝试获取互斥锁，再判断是否需要缓存重建 String lockKey = LOCK_SHOP_KEY_PREFIX + id; // 判断是否获取锁成功 if (tryLock(lockKey)) { // 在线程1重建缓存期间，线程2进行过期判断，假设此时key是过期状态，线程1重建完成并释放锁，线程2立刻获取锁，并启动异步线程执行重建，那此时的重建就与线程1的重建重复了 // 因此需要在线程2获取锁成功后，在这里再次检测redis中缓存是否过期（DoubleCheck），如果未过期则无需重建缓存，防止数据过期之后，刚释放锁就有线程拿到锁的情况，重复访问数据库进行重建 shopJson = stringRedisTemplate.opsForValue().get(cacheKey); // 缓存命中，先把json反序列化为逻辑过期对象 redisData = JSONUtil.toBean(shopJson, RedisData.class); // 将Object对象转成JSONObject再反序列化为目标对象 shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class); // 判断是否逻辑过期 if (redisData.getExpireTime().isAfter(LocalDateTime.now())) { // 命中且未过期，直接返回新数据 return Result.ok(shop); } // 获取锁成功，开启一个独立子线程去重建缓存 CACHE_REBUILD_EXECUTOR.submit(() -\u0026gt; { try { // 缓存重建并设置逻辑过期时间 saveShop2Cache(id, CACHE_SHOP_LOGICAL_TTL); } finally { // 释放锁 unLock(lockKey); } }); } // 获取锁失败，直接返回过期的旧数据 return Result.ok(shop); } /** * 根据商铺id查询店铺数据，并将数据封装逻辑过期时间，保存到缓存中（缓存预热、重建缓存使用） * - 逻辑过期时间根据具体业务而定，逻辑过期过长，会造成缓存数据的堆积，浪费内存；过短造成频繁缓存重建，降低性能。 * - 所以设置逻辑过期时间时，需要实际测试和评估不同参数下的性能和资源消耗情况，可以通过观察系统的表现，在业务需求和性能要求之间找到一个平衡点 * @param id 商铺id * @param expireSeconds 有效期（单位：秒） */ public void saveShop2Cache(Long id, Long expireSeconds) { // 查询店铺数据 Shop shop = getById(id); // 封装逻辑过期数据（热点数据） RedisData redisData = new RedisData(); redisData.setData(shop); // 设置缓存数据 redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds)); // 设置逻辑过期时间=当前时间+有效期TTL // 将逻辑过期数据写入Redis，不设置TTL过期时间，key永久有效，真正的过期时间为逻辑过期时间 stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY_PREFIX + id, JSONUtil.toJsonStr(redisData)); } 测试 先对缓存进行热点数据预热，预热数据的逻辑过期时间设置了20秒，所以等待20秒缓存逻辑过期后，在数据库中修改id为1的店铺数据（热点数据），使用Jmeter进行压力测试（可以将重建缓存的过程延迟1秒钟，让效果更加明显），第一次查询结果还是旧数据，1秒左右以后返回的缓存数据发生了更新（因为此时缓存已经重建好了），并且访问数据库只重建了一次缓存，验证了并发安全问题。\n10、封装Redis工具类\r基于StringRedisTemplate封装一个缓存工具类，满足下列需求：\n方法1：将任意Java对象序列化为json并存储在string类型的key中，并且可以设置TTL过期时间 方法2：将任意Java对象序列化为json并存储在string类型的key中，并且可以设置逻辑过期时间，用于处理缓存击穿问题 方法3：根据指定的key查询缓存，并反序列化为指定类型，利用缓存空值的方式解决缓存穿透问题 方法4：根据指定的key查询缓存，并反序列化为指定类型，需要利用逻辑过期解决缓存击穿问题 方法1与方法3对应，负责非热点数据的缓存，利用空值法解决缓存穿透；\n方法2与方法4对应，负责热点数据的缓存，利用逻辑过期解决缓存击穿。\nCacheClient工具类 @Slf4j @Component public class CacheClient { private final StringRedisTemplate stringRedisTemplate; public CacheClient(StringRedisTemplate stringRedisTemplate) { this.stringRedisTemplate = stringRedisTemplate; } /** * 将数据加入Redis，并设置有效期 * * @param key 缓存key * @param value 缓存数据值 * @param time 有效时间 * @param unit 有效时间单位 */ public void set(String key, Object value, long time, TimeUnit unit) { stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit); } /** * 将数据加入Redis，并设置逻辑过期时间（实际有效期为永久） * * @param key 缓存key * @param value 缓存数据值 * @param expireTime 逻辑过期时间 * @param unit 时间单位 */ public void setWithLogicalExpire(String key, Object value, long expireTime, TimeUnit unit) { // 封装逻辑过期数据（热点数据） RedisData redisData = new RedisData(); // 设置缓存数据 redisData.setData(value); // 设置逻辑过期时间=当前时间+有效期TTL，unit.toSeconds()将时间统一转换为秒 redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(expireTime))); // 将逻辑过期数据写入Redis，不设置TTL过期时间，key永久有效，真正的过期时间为逻辑过期时间 stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData)); } /** * 根据id查询数据（使用缓存空值法解决缓存穿透） * * @param keyPrefix 缓存key前缀 * @param id 查询id，与缓存key前缀拼接 * @param type 查询数据的Class类型 * @param dbFallback 根据id查询数据的函数式接口 * @param time 有效期 * @param unit 时间单位 * @param \u0026lt;R\u0026gt; * @param \u0026lt;ID\u0026gt; * @return */ public \u0026lt;R, ID\u0026gt; R handleCachePenetrationByBlankValue(String keyPrefix, ID id, Class\u0026lt;R\u0026gt; type, Function\u0026lt;ID, R\u0026gt; dbFallback, long time, TimeUnit unit) { // 从缓存中查询数据 String cacheKey = keyPrefix + id; String json = stringRedisTemplate.opsForValue().get(cacheKey); // 判断缓存是否命中 if (StrUtil.isNotBlank(json)) { // 缓存命中，直接返回 return JSONUtil.toBean(json, type); } // 防止缓存穿透：缓存未命中，判断缓存中命中的是否是空值（isNotBlank把null和空字符串给排除了） if (\u0026#34;\u0026#34;.equals(json)) { // 当前数据是空字符串（说明该数据是之前缓存的空对象），返回null return null; } // 缓存未命中，交给调用者查询数据库 R r = dbFallback.apply(id); // 数据库中不存在，将空值写入redis if (r == null) { stringRedisTemplate.opsForValue().set(cacheKey, \u0026#34;\u0026#34;, CACHE_NULL_TTL, TimeUnit.MINUTES); // 返回null return null; } // 数据库中存在，重建缓存，并返回店铺数据 this.set(cacheKey, r, time, unit); return r; } /** * 缓存重建线程池 */ private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10); /** * 根据id查询热点数据（使用逻辑过期解决缓存击穿） * * @param cacheKeyPrefix 缓存key前缀 * @param id 查询id，与缓存key前缀拼接 * @param type 查询数据的Class类型 * @param lockKeyPrefix 缓存数据锁前缀，与查询id拼接 * @param dbFallback 根据id查询数据的函数式接口 * @param expireTime 逻辑过期时间 * @param unit 时间单位 * @param \u0026lt;R\u0026gt; * @param \u0026lt;ID\u0026gt; * @return */ public \u0026lt;R, ID\u0026gt; R handleCacheBreakdownByLogicalExpire(String cacheKeyPrefix, ID id, Class\u0026lt;R\u0026gt; type, String lockKeyPrefix, Function\u0026lt;ID, R\u0026gt; dbFallback, long expireTime, TimeUnit unit) { // 从缓存中获取热点数据 String cacheKey = cacheKeyPrefix + id; String json = stringRedisTemplate.opsForValue().get(cacheKey); // 判断缓存是否命中（由于是热点数据，提前进行缓存预热，默认缓存一定会命中） if (StrUtil.isBlank(json)) { // 缓存未命中，说明查到的不是热点key，直接返回空 return null; } // 缓存命中，先把json反序列化为逻辑过期对象 RedisData redisData = JSONUtil.toBean(json, RedisData.class); // 将Object对象转成JSONObject再反序列化为目标对象 R r = JSONUtil.toBean((JSONObject) redisData.getData(), type); // 判断是否逻辑过期 if (redisData.getExpireTime().isAfter(LocalDateTime.now())) { // 未过期，直接返回正确数据 return r; } // 已过期，先尝试获取互斥锁，再判断是否需要缓存重建 String lockKey = lockKeyPrefix + id; // 判断是否获取锁成功 if (tryLock(lockKey)) { // 在线程1重建缓存期间，线程2进行过期判断，假设此时key是过期状态，线程1重建完成并释放锁，线程2立刻获取锁，并启动异步线程执行重建，那此时的重建就与线程1的重建重复了 // 因此需要在线程2获取锁成功后，在这里再次检测redis中缓存是否过期（DoubleCheck），如果未过期则无需重建缓存，防止数据过期之后，刚释放锁就有线程拿到锁的情况，重复访问数据库进行重建 json = stringRedisTemplate.opsForValue().get(cacheKey); // 缓存命中，先把json反序列化为逻辑过期对象 redisData = JSONUtil.toBean(json, RedisData.class); // 将Object对象转成JSONObject再反序列化为目标对象 r = JSONUtil.toBean((JSONObject) redisData.getData(), type); // 判断是否逻辑过期 if (redisData.getExpireTime().isAfter(LocalDateTime.now())) { // 命中且未过期，直接返回新数据 return r; } // 获取锁成功，开启一个独立子线程去重建缓存 CACHE_REBUILD_EXECUTOR.submit(() -\u0026gt; { try { // 查询数据库 R result = dbFallback.apply(id); // 写入redis this.setWithLogicalExpire(cacheKey, result, expireTime, unit); } finally { // 释放锁 unLock(lockKey); } }); } // 获取锁失败，直接返回过期的旧数据 return r; } /** * 尝试获取锁，判断是否获取锁成功 * setIfAbsent()：如果缺失不存在这个key，则可以set，返回true；存在key不能set，返回false。相当于setnx命令 * @param lockKey 互斥锁的key * @return 是否获取到锁 */ private boolean tryLock(String lockKey) { // 原子命令：set lock value ex 10 nx Boolean isGetLock = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, \u0026#34;1\u0026#34;, 10L, TimeUnit.SECONDS); // 为了避免Boolean直接返回自动拆箱未null，用工具包将null和false都返回为false return BooleanUtil.isTrue(isGetLock); } /** * 释放互斥锁 * @param lockKey 互斥锁的key */ private void unLock(String lockKey) { if (BooleanUtil.isTrue(stringRedisTemplate.hasKey(lockKey))) { stringRedisTemplate.delete(lockKey); } } } 在ShopServiceImpl中替换为工具类 @Resource private CacheClient cacheClient; /** * 根据id查询商铺信息 * @param id 商铺id * @return 商铺详情数据 */ @Override public Result queryById(Long id) { // 用空值法解决缓存穿透 //Shop shop = cacheClient.handleCachePenetrationByBlankValue(CACHE_SHOP_KEY_PREFIX, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES); // 用逻辑过期解决缓存击穿 Shop shop = cacheClient.handleCacheBreakdownByLogicalExpire(CACHE_SHOP_KEY_PREFIX, id, Shop.class, LOCK_SHOP_KEY_PREFIX, this::getById, CACHE_SHOP_LOGICAL_TTL, TimeUnit.SECONDS); if (shop == null) { return Result.fail(\u0026#34;店铺不存在\u0026#34;); } return Result.ok(shop); } 四、优惠券秒杀\r1、全局唯一ID\r（1）数据库自增ID存在的问题\r当用户抢购时，就会生成订单并保存到tb_voucher_order这张表中，而订单表如果使用数据库自增ID就存在一些问题：\nid的规律性太明显 场景分析一：如果我们的id具有太明显的规则，用户或者说商业对手很容易猜测出来我们的一些敏感业务信息，比如商城在一天时间内，卖出了多少单，这明显不合适。 受单表数据量的限制 场景分析二：随着我们商城规模越来越大，mysql的单表的容量不宜超过500W，数据量过大之后，我们要进行分库分表，但拆分表了之后，他们从逻辑上讲他们是同一张表，所以他们的id是不能一样的， 于是乎我们需要保证id的全局唯一性。 全局ID生成器，是一种在分布式系统下用来生成全局唯一ID的工具，一般要满足下列特性：\n![](Java/Java Web/Redis/files/04a7c67839ea27a7356ec1d4494820c2_MD5.png)\n（2）基于Redis自增器实现分布式全局ID\r分布式ID的实现方式：\nUUID（生成16进制字符串，字符串id不利于数据库作为索引查询） Redis自增（自定义的方式实现：时间戳+序列号+数据库自增） 数据库自增（单独维护一个全局id表，作为多张表的全局id） snowflake算法（雪花算法） 这里我们基于Redis的自增器实现生成分布式ID，为了增加ID的安全性，我们可以不直接使用Redis自增的数值，而是在Redis自增器基础上拼接一些其它信息：\n![](Java/Java Web/Redis/files/7ea1ec1eae279957f49e7c57e6190fd8_MD5.png)\n我们本次使用数值型id，也就是Java中的long类型，占用8个字节。\nID的组成部分：符号位：1bit，永远为0\n时间戳（id生成时间 - 初始时间的秒数）：31bit，以秒为单位，大概可以支持使用69年（2^31/3600/24/365≈69）\n序列号（Redis自增后的value）：32bit，秒内的计数器，支持每秒产生2^32个不同ID\n为了增加ID的安全性，我们可以不直接使用Redis自增的数值，而是拼接一些其它信息，比如时间戳、UUID、业务关键词\n例如，计算下单时间戳，利用下单时间 - 初始时间的时间差秒数作为时间戳。首先我们需要生成一个初始时间：\n// 获取指定日期的时间戳 LocalDateTime time = LocalDateTime.of(2024, 1, 1, 0, 0, 0); // 将时间戳转换为秒数 long second = time.toEpochSecond(ZoneOffset.UTC); // 例如2024年1月1日0时0分0秒的时间戳秒数second = 1704067200（初始时间秒数） System.out.println(\u0026#34;second = \u0026#34; + second); 全局唯一ID生成器RedisIDWorker @Component public class RedisIDWorker { /** * 初始时间戳秒数 */ private static final long BEGIN_TIMESTAMP = 1704067200L; /** * 序列号位数 */ private static final int SEQUENCE_BITS = 32; private StringRedisTemplate stringRedisTemplate; public RedisIDWorker(StringRedisTemplate stringRedisTemplate) { this.stringRedisTemplate = stringRedisTemplate; } /** * 获取自增后的全局唯一ID * 注意：单个key我们需要保证key不相同，即使是同一个业务内的key也不能相同。 * Redis中对同一个key做自增，value最多到2^64，而我们的全局唯一ID（value）策略里，真正记录序列号的位数最多只有32bit * 因此我们在业务key前缀后面拼接一个当天日期，这样设计保证每天都自增的是一个新key的id，而一天的下单量不可能超过2^32个 * @param keyPrefix 业务key前缀 * @return 自增后的全局唯一ID */ public long nextId(String keyPrefix) { // 生成时间戳部分 LocalDateTime now = LocalDateTime.now(); long nowSecond = now.toEpochSecond(ZoneOffset.UTC); long timestamp = nowSecond - BEGIN_TIMESTAMP; // 生成序列号部分 // 获取当前日期，精确到天（好处1：避免自增相同key的value超过32位上限；好处2：根据key中的日期，方便进行统计） String date = now.format(DateTimeFormatter.ofPattern(\u0026#34;yyyy:MM:dd\u0026#34;)); String key; if (!keyPrefix.endsWith(\u0026#34;:\u0026#34;)) { key = \u0026#34;incr:\u0026#34; + keyPrefix + \u0026#34;:\u0026#34; + date; }else { key = \u0026#34;incr:\u0026#34; + keyPrefix + date; } // 根据key对value（分布式全局ID）做自增长，如果key不存在，则自动创建该key并返回自增后的结果 long sequence = stringRedisTemplate.opsForValue().increment(key); // 返回自增后的全局唯一ID，将timestamp从最低位左移SEQUENCE_BITS位，低SEQUENCE_BITS位补0，再与sequence做或运算填充 return timestamp \u0026lt;\u0026lt; SEQUENCE_BITS | sequence; } } 测试生成分布式ID效果 @Resource private RedisIDWorker redisIDWorker; // 利用线程池测试并发环境下，测试生成分布式全局ID private ExecutorService es = Executors.newFixedThreadPool(500); @Test void testIdWorker() throws InterruptedException { // CountDownLatch相当于线程计数器，参数值代表了需要进行多少次任务 CountDownLatch latch = new CountDownLatch(300); Runnable task = () -\u0026gt; { for (int i = 0; i \u0026lt; 100; i++) { long id = redisIDWorker.nextId(\u0026#34;order\u0026#34;); System.out.println(\u0026#34;id = \u0026#34; + id); } // 每执行完一次任务（生成100个id），该子线程就调用countDown()方法-1，直到减为0 latch.countDown(); }; long begin = System.currentTimeMillis(); for (int i = 0; i \u0026lt; 300; i++) { es.submit(task); // 任务提交300次，每个任务生成100个id，共生成30000个id } // await谁调用就是让谁暂停，要等CountDownLatch计数器为0，才能进行主线程 latch.await(); long end = System.currentTimeMillis(); System.out.println(\u0026#34;time = \u0026#34; + (end - begin)); } 关于CountDownLatch CountDownLatch名为信号枪：主要的作用是同步协调在多线程的等待与唤醒问题。\n如果不使用CountDownLatch，那么由于程序是异步的，当异步程序没有执行完时，主线程就已经执行完了，然后我们期望的是分线程全部走完之后，主线程再走，此时就需要使用到CountDownLatch。\nCountDownLatch中有两个最重要的方法：\ncountDown() await() await()是阻塞方法，我们担心分线程没有执行完时，main线程就先执行，所以使用await()可以让main线程阻塞，那么什么时候main线程不再阻塞呢？我们创建 CountDownLatch(int count) 的时候会给一个参数作为内部变量初始值，当CountDownLatch内部维护的变量变为0时，就不再阻塞，直接放行，那么什么时候CountDownLatch维护的变量变为0呢，我们只需要调用一次countDown() ，内部变量就减少1，我们让分线程和内部变量绑定， 执行完一个分线程就减少一个变量，当分线程全部走完 ，CountDownLatch维护的变量就是0，此时await就不再阻塞，统计出来的时间也就是所有分线程执行完后的时间。\n![](Java/Java Web/Redis/files/aa85884f18a511739bfff7da5be03e3f_MD5.png)\n2、实现秒杀下单优惠券功能\r每个店铺都可以发布优惠券，分为 平价券 和 特价券（秒杀券）。平价券可以任意购买，而特价券需要秒杀抢购：\n![](Java/Java Web/Redis/files/a9c650ddf18e1a0d394b13280adf523e_MD5.png)\ntb_voucher：优惠券的基本信息，优惠金额、使用规则等 tb_seckill_voucher：优惠券的基本信息、优惠券的库存、开始抢购时间，结束抢购时间。特价优惠券才需要填写这些信息 -- tb_voucher 优惠券表 create table tb_voucher ( id bigint unsigned auto_increment comment \u0026#39;主键\u0026#39; primary key, shop_id bigint unsigned null comment \u0026#39;商铺id\u0026#39;, title varchar(255) not null comment \u0026#39;代金券标题\u0026#39;, sub_title varchar(255) null comment \u0026#39;副标题\u0026#39;, rules varchar(1024) null comment \u0026#39;使用规则\u0026#39;, pay_value bigint unsigned not null comment \u0026#39;支付金额，单位是分。例如200代表2元\u0026#39;, actual_value bigint not null comment \u0026#39;抵扣金额，单位是分。例如200代表2元\u0026#39;, type tinyint unsigned default \u0026#39;0\u0026#39; not null comment \u0026#39;0,普通券；1,秒杀券\u0026#39;, status tinyint unsigned default \u0026#39;1\u0026#39; not null comment \u0026#39;1,上架; 2,下架; 3,过期\u0026#39;, create_time timestamp default CURRENT_TIMESTAMP not null comment \u0026#39;创建时间\u0026#39;, update_time timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment \u0026#39;更新时间\u0026#39; ) collate = utf8mb4_general_ci; -- tb_seckill_voucher 秒杀优惠券表，与优惠券是一对一关系 create table tb_seckill_voucher ( voucher_id bigint unsigned not null comment \u0026#39;关联的优惠券的id\u0026#39; primary key, stock int not null comment \u0026#39;库存\u0026#39;, create_time timestamp default CURRENT_TIMESTAMP not null comment \u0026#39;创建时间\u0026#39;, begin_time timestamp default CURRENT_TIMESTAMP not null comment \u0026#39;生效时间\u0026#39;, end_time timestamp default CURRENT_TIMESTAMP not null comment \u0026#39;失效时间\u0026#39;, update_time timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment \u0026#39;更新时间\u0026#39; ) comment \u0026#39;秒杀优惠券表，与优惠券是一对一关系\u0026#39; collate = utf8mb4_general_ci; 平价券由于优惠力度并不是很大，所以是可以任意发放领取 而特价券由于优惠力度大，所以像第二种券，就得限制数量和抢购时间 秒杀优惠券表，与优惠券是一对一关系，通过优惠券的id关联优惠券表，相当于对优惠券表做了字段的扩展 新增普通券和秒杀券代码： VoucherController\n/** * 新增普通券 * @param voucher 优惠券信息 * @return 优惠券id */ @PostMapping public Result addVoucher(@RequestBody Voucher voucher) { voucherService.save(voucher); return Result.ok(voucher.getId()); } /** * 新增秒杀券 * @param voucher 优惠券信息，包含秒杀信息 * @return 优惠券id */ @PostMapping(\u0026#34;seckill\u0026#34;) public Result addSecKillVoucher(@RequestBody Voucher voucher) { voucherService.addSecKillVoucher(voucher); return Result.ok(voucher.getId()); } VoucherServiceImpl：新增普通券和秒杀券共用同一个业务方法addSecKillVoucher() @Override @Transactional public void addSecKillVoucher(Voucher voucher) { // 保存优惠券 save(voucher); // 保存秒杀信息 SeckillVoucher seckillVoucher = new SeckillVoucher(); seckillVoucher.setVoucherId(voucher.getId()); seckillVoucher.setStock(voucher.getStock()); seckillVoucher.setBeginTime(voucher.getBeginTime()); seckillVoucher.setEndTime(voucher.getEndTime()); seckillVoucherService.save(seckillVoucher); } 有了新增秒杀券的接口后，要秒杀下单优惠券，需要先有优惠券。由于该项目没有管理后台，因此通过ApiFox或Postman请求该接口，添加优惠券保存到数据库\nPOST http://localhost:8081/voucher/seckill { \u0026#34;shopId\u0026#34;: 1, \u0026#34;title\u0026#34;: \u0026#34;100元代金券\u0026#34;, \u0026#34;subTitle\u0026#34;: \u0026#34;周一至周五均可使用\u0026#34;, \u0026#34;rules\u0026#34;: \u0026#34;全场通用\\\\n无需预约\\\\n可无限叠加\\\\不兑现、不找零\\\\n仅限堂食\u0026#34;, \u0026#34;payValue\u0026#34;: 8000, \u0026#34;actualValue\u0026#34;: 10000, \u0026#34;type\u0026#34;: 1, \u0026#34;stock\u0026#34;: 100, \u0026#34;beginTime\u0026#34;: \u0026#34;2022-01-25T10:09:17\u0026#34;, \u0026#34;endTime\u0026#34;: \u0026#34;2037-01-26T12:09:04\u0026#34; } 确保添加的优惠券没有过期，即 endTime \u0026gt; 当前时间 且 endTime \u0026gt; beginTime 注意：在MySQL中，TIMESTAMP占用4个字节、并且查询的时候系统会帮你自动转成（Y-m-d H:i:s),可读性强。TIMESTAMP 的取值范围是 '1970-01-01 00:00:01' UTC 到 '2038-01-19 03:14:07' UTC。这是因为它使用 32 位存储空间，而这个空间能够表示的秒数是有限的，最多只能表示到 2038 年。\n下单核心思路：当我们点击抢购时，会触发右侧的请求，我们只需要编写对应的controller即可 ![](Java/Java Web/Redis/files/61a03ad7875d67576b38dbd4c6ddece8_MD5.png)\n下单时需要判断两点：\n秒杀是否开始或结束，如果尚未开始或已经结束则无法下单 库存是否充足，不足则无法下单 ![](Java/Java Web/Redis/files/93e5654508d7acb6b79c7e4d5b5f6da3_MD5.png)\n下单核心逻辑分析：\n当用户开始进行下单，我们应当去查询优惠卷信息，查询到优惠卷信息，判断是否满足秒杀条件，如果是否在秒杀活动时间内，则进一步判断库存是否足够，如果两者都满足，则扣减库存，创建订单，然后返回订单id，如果有一个条件不满足则直接结束。\nVoucherOrderServiceImpl @Resource private ISeckillVoucherService seckillVoucherService; @Resource private RedisIDWorker redisIDWorker; /** * 秒杀下单优惠券 * @param voucherId 优惠券id * @return 下单id */ @Override @Transactional public Result seckillVoucher(Long voucherId) { // 查询秒杀优惠券信息 SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId); // 判断秒杀是否开始 if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) { // 秒杀活动尚未开始 return Result.fail(\u0026#34;秒杀尚未开始！\u0026#34;); } // 判断秒杀是否结束 if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) { // 秒杀活动已经结束 return Result.fail(\u0026#34;秒杀已经结束！\u0026#34;); } // 判断库存是否充足 if (seckillVoucher.getStock() \u0026lt; 1) { // 秒杀券库存不足 return Result.fail(\u0026#34;秒杀券已抢空！\u0026#34;); } // 扣减库存 boolean success = seckillVoucherService.update() .setSql(\u0026#34;stock = stock - 1\u0026#34;) .eq(\u0026#34;voucher_id\u0026#34;, voucherId) .update(); if (!success) { // 扣减失败 throw new RuntimeException(\u0026#34;扣减失败，秒杀券扣减失败（库存不足）！\u0026#34;); } // 创建订单 VoucherOrder voucherOrder = new VoucherOrder(); voucherOrder.setId(redisIDWorker.nextId(SECKILL_VOUCHER_ORDER)); // 订单id voucherOrder.setUserId(((UserVo) BaseContext.get()).getId()); // 用户id voucherOrder.setVoucherId(voucherId); // 代金券id success = this.save(voucherOrder); if (!success) { // 创建秒杀券订单失败 throw new RuntimeException(\u0026#34;创建秒杀券订单失败！\u0026#34;); } // 返回订单id return Result.ok(voucherOrder.getId()); } 修改前端代码，解决抢券下单后页面不更新最新优惠券数量问题 ![](Java/Java Web/Redis/files/c073ca40a58b758fd6ebebeadec2d87c_MD5.png)\n3、单体服务下一人多单超卖问题\r我们利用分布式ID完成了优惠券秒杀的下单基本功能，但是在高并发场景下可能会存在超卖问题。我们将数据库中的秒杀券库存恢复到100，并且清空优惠券订单表，使用JMeter来还原一下单体架构下的一人下多单超卖场景。\n为了方便测试，先把全局异常处理器WebExceptionAdvice中的RuntimeException拦截注释掉，方便我们在JMeter中查看请求的异常率（如果不注释直接返回Result.fail()或者抛出RuntimeException，JMeter这边也会显示请求成功）。\n/** * 全局异常处理器 */ @Slf4j @RestControllerAdvice public class WebExceptionAdvice { //@ExceptionHandler(RuntimeException.class)\t// 暂时注释掉，方便测试 public Result handleRuntimeException(RuntimeException e) { log.error(e.toString(), e); return Result.fail(\u0026#34;服务器异常\u0026#34;); } } 秒杀业务需要用户登录，因此在JMeter的HTTP信息头管理器中设置token，模拟一个用户同时发送多次下单请求。\n![](Java/Java Web/Redis/files/4761ca76c86e144b64d1f5736f557b0e_MD5.png)\n![](Java/Java Web/Redis/files/3565d9671d6701f3ba5cbef9da489ce9_MD5.png)\n设置线程数为200，正常情况下应该是卖出100张，另外50%请求抢券失败，而结果异常率却是56.50%\n![](Java/Java Web/Redis/files/a3b571710ef1044f69b3b65fa5aa999e_MD5.png)\n数据库中下单数量为109，库存为-9，这就是高并发场景下的超卖问题。\n![](Java/Java Web/Redis/files/39a5096cdb93fcb78aee20a8dd2f2be3_MD5.png)\n为什么会产生超卖问题呢？\n![](Java/Java Web/Redis/files/90cdc8aaa0fe1ff6af66737a9f4dcb2f_MD5.png)\n线程1过来查询库存，发现库存充足，正准备去扣减库存，但是还没有来得及去扣减，此时线程2过来，线程2也去查询库存，同样发现库存充足，那么这两个线程都会去扣减库存，最终相当于多个线程一起去扣减库存，导致库存变成了负数，这就是库存超卖问题出现的原因。\n超卖问题的常见解决方案\n悲观锁：认为线程安全问题一定会发生，因此操作数据库之前都需要先获取锁，确保线程串行执行。悲观锁中又可以再细分为公平锁、非公平锁、可重入锁等等。常见的悲观锁有：synchronized、lock。 乐观锁：认为线程安全问题不一定发生，因此不加锁，只会在更新数据库的时候去判断有没有其它线程对数据进行修改，如果没有修改则认为是安全的，直接更新数据库中的数据即可，如果修改了则说明不安全，直接抛异常或者等待重试。常见的实现方式有：版本号法、CAS操作。版本号法可以用来解决ABA问题 悲观锁和乐观锁的比较\n悲观锁和乐观锁的解决共享变量冲突方式不同：悲观锁在冲突发生时直接阻塞其他线程；乐观锁则是在提交阶段检查冲突并进行重试。 悲观锁比乐观锁的性能低：悲观锁需要先加锁再操作，限制了并发性能；而乐观锁不需要加锁，所以乐观锁通常具有更好的性能。 应用场景：两者都是互斥锁，悲观锁适合写入操作较多、冲突频繁的场景；乐观锁适合读取操作较多、冲突较少的场景。 4、乐观锁解决一人多单超卖问题\r实现方式一：CAS版本号法 ![](Java/Java Web/Redis/files/ed854a4441bb1b3fff91e5ca26623ccd_MD5.png)\n首先我们要为 tb_seckill_voucher 表新增一个版本号字段 version ，线程1查询完库存，在进行库存扣减操作的同时将版本号+1，线程2在查询库存时，同时查询出当前的版本号，发现库存充足，也准备执行库存扣减操作，但是需要判断当前的版本号是否和之前查询时的版本号一致，结果发现版本号发生了改变，这就说明数据库中的数据已经被其他线程修改，需要进行重试（或者直接抛异常中断）\n实现方式二：CAS法 ![](Java/Java Web/Redis/files/54de5adc030e54ee1087f7095c93adaa_MD5.png)\nCAS法类似与版本号法，但是不需要另外在添加一个 version 字段，而是直接使用库存替代版本号，线程1查询完库存后进行库存扣减操作，线程2在查询库存时，发现库存充足，也准备执行库存扣减操作，但是需要判断当前的库存是否和之前查询时的库存一致，结果发现库存数量发生了改变，这就说明数据库中的数据已经被其他线程修改，需要进行重试（或者直接抛异常中断）\n拓展：CAS（Compare and Swap）是一种并发编程中常用的原子操作，用于解决多线程环境下的数据竞争问题。它是基于乐观锁思想的一种实现方式。CAS体现的是无锁并发、无阻塞并发。\nCAS操作包含三个参数：内存地址V、旧的预期值A和新的值B。CAS的执行过程如下：\n比较（Compare）：将内存地址V中的值与预期值A进行比较。 判断（Judgment）：如果相等，则说明当前值和预期值相等，表示没有发生其他线程的修改。 交换（Swap）：使用新的值B来更新内存地址V中的值。 CAS操作是一个原子操作，意味着在执行过程中不会被其他线程中断，保证了线程安全性。CAS在硬件层面使用了cmpxchg（x86架构）原子指令，处理器会自动锁定总线，防止其他 CPU 访问共享变量，然后执行比较和交换操作，最后释放总线，避免了线程的上下文切换和内核态的开销。如果CAS操作失败（即当前值与预期值不相等），通常会进行重试，直到CAS操作成功为止。\nCAS也有一些限制和适用条件：\nCAS操作能够保证原子性，但无法解决ABA问题（某个值先变为A，后又变回原来的A，那么在CAS检查时可能无法识别出这种变化）。为了解决ABA问题，可以使用带有版本号或时间戳的CAS。 CAS操作适合在高度竞争的情况下使用，当竞争不激烈时，使用传统锁可以更好地处理。 CAS操作只能针对单个变量的原子操作，无法实现复杂的同步需求。而传统锁可以支持更复杂的同步机制，如读写锁、悲观锁等。 由于我们是下订单秒杀业务，需要维护的更新的字段只有库存一个，因此更适合CAS法，即不需要添加version字段，根据库存字段变化来判断是否执行更新，如果数据库查询的库存和当前数据库中的库存数量不一致，则不执行更新操作。\n// 扣减库存 boolean success = seckillVoucherService.update() .setSql(\u0026#34;stock = stock - 1\u0026#34;) // set stoke = stoke - 1 // where id = ? and stock = ? .eq(\u0026#34;voucher_id\u0026#34;, voucherId) .eq(\u0026#34;stock\u0026#34;, seckillVoucher.getStock()) .update(); 将数据库订单全部删除，库存还原回100，JMeter压测qps200 ![](Java/Java Web/Redis/files/4c8b8a63cc9d9b51fc9c90424979d599_MD5.png)\n结果是超卖成负数的问题解决了，但是一共100张库存，订单只卖了20张，库存还有80张没有卖出，还是存在超卖问题。正常来说应该是100个线程买100张，另外100个线程返回库存不足。\n![](Java/Java Web/Redis/files/81ae5143d5e599328a2895958f892260_MD5.png)\n这个原因其实就是乐观锁的弊端，成功的概率太低。只要发现数据被修改的不一致就直接终止操作了，而其他线程扣减时的库存和之前从数据库查询到的库存数量很多都不一样，所以没有更新成功。因此我们只需要修改一下判断条件，即只要库存stock \u0026gt; 0满足我们当前的业务逻辑就可以进行修改，而不是库存数据修改就终止更新操作。\n// 扣减库存 boolean success = seckillVoucherService.update() .setSql(\u0026#34;stock = stock - 1\u0026#34;) // set stoke = stoke - 1 .eq(\u0026#34;voucher_id\u0026#34;, voucherId).gt(\u0026#34;stock\u0026#34;, 0)\t// where id = ? and stock \u0026gt; 0 .update(); // 或者使用LambdaUpdateWrapper更新，防止字段写错 boolean success = seckillVoucherService.update(new LambdaUpdateWrapper\u0026lt;SeckillVoucher\u0026gt;() // set stoke = stoke - 1 .setSql(\u0026#34;stock = stock -1\u0026#34;) // where id = ? and stock \u0026gt; 0 .eq(SeckillVoucher::getVoucherId, voucherId) .gt(SeckillVoucher::getStock, 0)); 再次测试，一人下多单情况下超卖问题解决！100张优惠券正常卖出，下单量100，另外100个线程用户没有抢到秒杀券。 ![](Java/Java Web/Redis/files/b0bf718018c6a3d2bf07abe27f52671e_MD5.png)\n![](Java/Java Web/Redis/files/8f0d27cc35762288e1de7b68b49b6bca_MD5.png)\n5、单体服务下一人一单超卖问题\r需求：修改秒杀业务，要求同一个优惠券，一个用户只能下一单\n![](Java/Java Web/Redis/files/e1564cb377dea136805e49e44dec2f3c_MD5.png)\n现在的问题在于：\n优惠券是为了引流，但是目前的情况是，一个人可以无限制的抢这个优惠券，所以我们应当增加一层判断逻辑，让一个用户只能下一个单，而不是让一个用户下多个单。\n具体操作逻辑如下：比如时间是否充足，如果时间充足，则进一步判断库存是否足够，然后再根据优惠券id和用户id查询是否已经下过这个订单，如果下过这个订单，则不再下单，否则进行下单。\n// 判断是否是一人一单 Long userId = ((UserVo) BaseContext.get()).getId(); // 根据用户id和优惠券id查询订单是否存在 int count = query().eq(\u0026#34;user_id\u0026#34;, userId).eq(\u0026#34;voucher_id\u0026#34;, voucherId).count(); if (count \u0026gt; 0) { // 该用户已经购买过了，不允许下多单 return Result.fail(\u0026#34;该秒杀券用户已经购买过一次了！\u0026#34;); } 使用JMeter测试是否完成一人一单要求，结果是库存90，同一人下了10单，还是存在一人一单超卖问题。\n![](Java/Java Web/Redis/files/9fed31e040a675d29aa48d6d46b42124_MD5.png)\n这个原因其实和多线程并发下一人多单超卖成负数的问题一样，线程1查询当前用户是否有该优惠券的订单，当前用户没有订单准备下单，此时线程2也查询当前用户是否有订单，由于线程1还没有完成下单操作，线程2同样发现当前用户未下单，也准备下单，这样明明一个用户只能下一单，结果下了两单，也就出现了超卖问题。\n也就是说查询和判断是分开的, 使用锁就可以解决单体架构一人多单超卖问题, 6、悲观锁解决单体服务下一人一单超卖问题\r一般这种多线程超卖问题可以使用悲观锁、乐观锁两种常见的解决方案。乐观锁一般是用在数据更新时判断数据是否修改，而现在是判断订单是否存在（查询）并且下订单（插入），所以无法像解决库存下单超卖一样使用CAS机制，但是可以使用版本号法，而版本号法需要新增一个字段，所以选择使用悲观锁解决超卖问题。\n将下订单的逻辑抽取到createSecKillVoucherOrder()方法中，使用synchronized保证并发安全，使用@Transactional保证事务一致性 /** * 秒杀下单优惠券 * @param voucherId 优惠券id * @return 下单id */ @Override public Result seckillVoucher(Long voucherId) { // 查询秒杀优惠券信息 SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId); // 判断秒杀是否开始 if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) { // 秒杀活动尚未开始 return Result.fail(\u0026#34;秒杀尚未开始！\u0026#34;); } // 判断秒杀是否结束 if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) { // 秒杀活动已经结束 return Result.fail(\u0026#34;秒杀已经结束！\u0026#34;); } // 判断库存是否充足 if (seckillVoucher.getStock() \u0026lt; 1) { // 秒杀券库存不足 return Result.fail(\u0026#34;秒杀券已抢空！\u0026#34;); } return createSecKillVoucherOrder(voucherId); } @Transactional public synchronized Result createSecKillVoucherOrder(Long voucherId) { // 判断是否是一人一单 Long userId = ((UserVo) BaseContext.get()).getId(); // 根据用户id和优惠券id查询订单是否存在 int count = query().eq(\u0026#34;user_id\u0026#34;, userId).eq(\u0026#34;voucher_id\u0026#34;, voucherId).count(); if (count \u0026gt; 0) { // 该用户已经购买过了，不允许下多单 return Result.fail(\u0026#34;该秒杀券用户已经购买过一次了！\u0026#34;); } // 扣减库存 boolean success = seckillVoucherService.update() .setSql(\u0026#34;stock = stock - 1\u0026#34;) // set stoke = stoke - 1 .eq(\u0026#34;voucher_id\u0026#34;, voucherId).gt(\u0026#34;stock\u0026#34;, 0)\t// where id = ? and stock \u0026gt; 0 .update(); if (!success) { // 扣减失败 throw new RuntimeException(\u0026#34;扣减失败，秒杀券扣减失败（库存不足）！\u0026#34;); } // 创建订单 VoucherOrder voucherOrder = new VoucherOrder(); voucherOrder.setId(redisIDWorker.nextId(SECKILL_VOUCHER_ORDER)); // 订单id voucherOrder.setUserId(userId); // 用户id voucherOrder.setVoucherId(voucherId); // 代金券id success = this.save(voucherOrder); if (!success) { // 创建秒杀券订单失败 throw new RuntimeException(\u0026#34;创建秒杀券订单失败！\u0026#34;); } // 返回订单id return Result.ok(voucherOrder.getId()); } 但是这种方案存在的问题就是：synchronized加在方法上，锁的范围是整个方法，锁的对象是this，不管任何一个用户来了都要加这把锁，而且是同一把锁，锁的粒度太大，整个方法是串行执行，性能很差。而一人一单只是同一个用户多次请求下单才判断并发安全问题，如果不是同一个用户则不用加锁。因此我们加锁的对象不应该是VoucherOrderServiceImpl对象，而是给用户id加锁，也就是说不同用户加的是不同的锁，这样就可以缩小锁的范围。\n@Transactional public Result createSecKillVoucherOrder(Long voucherId) { // 判断是否是一人一单 Long userId = ((UserVo) BaseContext.get()).getId(); // intern()方法才能保证每个用户的锁对象唯一 synchronized(userId.toString().intern()) { // 根据用户id和优惠券id查询订单是否存在 int count = query().eq(\u0026#34;user_id\u0026#34;, userId).eq(\u0026#34;voucher_id\u0026#34;, voucherId).count(); if (count \u0026gt; 0) { // 该用户已经购买过了，不允许下多单 return Result.fail(\u0026#34;该秒杀券用户已经购买过一次了！\u0026#34;); } // 扣减库存 boolean success = seckillVoucherService.update() .setSql(\u0026#34;stock = stock - 1\u0026#34;) // set stoke = stoke - 1 .eq(\u0026#34;voucher_id\u0026#34;, voucherId).gt(\u0026#34;stock\u0026#34;, 0)\t// where id = ? and stock \u0026gt; 0 .update(); if (!success) { // 扣减失败 throw new RuntimeException(\u0026#34;扣减失败，秒杀券扣减失败（库存不足）！\u0026#34;); } // 创建订单 VoucherOrder voucherOrder = new VoucherOrder(); voucherOrder.setId(redisIDWorker.nextId(SECKILL_VOUCHER_ORDER)); // 订单id voucherOrder.setUserId(userId); // 用户id voucherOrder.setVoucherId(voucherId); // 代金券id success = this.save(voucherOrder); if (!success) { // 创建秒杀券订单失败 throw new RuntimeException(\u0026#34;创建秒杀券订单失败！\u0026#34;); } // 返回订单id return Result.ok(voucherOrder.getId()); } } 由于toString的源码底层是new String()，每次toString都是new了一个新字符串对象在堆中，所以如果我们只用userId.toString()拿到的也不是同一个用户，需要使用intern()方法，用intern()方法可以让同一个值的字符串对象不重复（放到了字符串常量池中）。如果字符串常量池中已经包含了一个等于值的String字符串对象，那么将返回池中的字符串地址引用；否则，将此String对象添加到池中，并返回对此String对象的引用。\n关于synchronized锁住对象： **（1）对于同步方法，锁当前对象（this） ****（2）对于静态同步方法，锁当前类的Class对象 **（3）对于同步代码块，锁住的是synchronized括号中的对象\n在createSecKillVoucherOrder()方法内部加锁还存在一个问题，就是下完订单后先释放锁，后进行事务提交，因为@Transactional加在方法上，是由Spring进行事务管理，所以事务的提交是在方法执行完以后由Spring进行提交。由于锁已经释放了，其他线程已经可以在提交事务前进来了，而因为事务尚未提交，数据还没有写入数据库，此时其他线程查询订单依然不存在，接着再去下单，因此存在并发安全问题。\n我们锁的范围小了，应该把整个方法锁起来。先获取锁，待方法执行完事务提交之后，再去释放锁，保证数据库更新的原子性，确保线程安全：\n// 判断是否是一人一单，如果是再去下订单 Long userId = ((UserVo) BaseContext.get()).getId(); // intern()方法才能保证每个用户的锁对象唯一 synchronized(userId.toString().intern()) { return this.createSecKillVoucherOrder(userId, voucherId); } 由于@Transactional是加在createSecKillVoucherOrder()上，而不是加在seckillVoucher()上。这里使用this.createSecKillVoucherOrder()调用，this是当前的VoucherOrderServiceImpl对象（目标对象），而不是它的代理对象。我们知道事务要想生效，其实是Spring对当前的VoucherOrderServiceImpl对象做了动态代理（Spring默认使用JDK动态代理，即对接口做代理，所以代理对象为IVoucherOrderService接口），拿到代理对象后去做事务处理。而当前的this非代理对象，而是目标对象，不具有事务功能。这个场景就是Spring事务失效的几种可能性之一。\nSpring事务失效解决方案 我们需要使用AopContext.currentProxy()方法拿到当前目标对象的代理对象IVoucherOrderService接口，该代理对象被Spring管理，因此使用带有事务功能的代理对象去调用createSecKillVoucherOrder()方法。\nVoucherOrderServiceImpl /** * 优惠券订单Service实现类 */ @Service public class VoucherOrderServiceImpl extends ServiceImpl\u0026lt;VoucherOrderMapper, VoucherOrder\u0026gt; implements IVoucherOrderService { @Resource private ISeckillVoucherService seckillVoucherService; @Resource private RedisIDWorker redisIDWorker; /** * 秒杀下单优惠券 * @param voucherId 优惠券id * @return 下单id */ @Override public Result seckillVoucher(Long voucherId) { // 查询秒杀优惠券信息 SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId); // 判断秒杀是否开始 if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) { // 秒杀活动尚未开始 return Result.fail(\u0026#34;秒杀尚未开始！\u0026#34;); } // 判断秒杀是否结束 if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) { // 秒杀活动已经结束 return Result.fail(\u0026#34;秒杀已经结束！\u0026#34;); } // 判断库存是否充足 if (seckillVoucher.getStock() \u0026lt; 1) { // 秒杀券库存不足 return Result.fail(\u0026#34;秒杀券已抢空！\u0026#34;); } // 判断是否是一人一单，如果是再去下订单 Long userId = ((UserVo) BaseContext.get()).getId(); // intern()方法才能保证每个用户的锁对象唯一 synchronized(userId.toString().intern()) { // 使用AopContext.currentProxy()方法拿到当前目标对象的代理对象 IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy(); // 使用带有事务功能的代理对象去调用 return proxy.createSecKillVoucherOrder(userId, voucherId); } } /** * 判断是否是一人一单，如果是再去创建秒杀券订单 * @param userId 用户id * @param voucherId 订单id * @return 订单id */ @Transactional public Result createSecKillVoucherOrder(Long userId, Long voucherId) { // 根据用户id和优惠券id查询订单是否存在 int count = query().eq(\u0026#34;user_id\u0026#34;, userId).eq(\u0026#34;voucher_id\u0026#34;, voucherId).count(); if (count \u0026gt; 0) { // 该用户已经购买过了，不允许下多单 return Result.fail(\u0026#34;该秒杀券用户已经购买过一次了！\u0026#34;); } // 扣减库存 boolean success = seckillVoucherService.update() .setSql(\u0026#34;stock = stock - 1\u0026#34;) // set stoke = stoke - 1 .eq(\u0026#34;voucher_id\u0026#34;, voucherId).gt(\u0026#34;stock\u0026#34;, 0)\t// where id = ? and stock \u0026gt; 0 .update(); if (!success) { // 扣减失败 throw new RuntimeException(\u0026#34;扣减失败，秒杀券扣减失败（库存不足）！\u0026#34;); } // 创建订单 VoucherOrder voucherOrder = new VoucherOrder(); voucherOrder.setId(redisIDWorker.nextId(SECKILL_VOUCHER_ORDER)); // 订单id voucherOrder.setUserId(userId); // 用户id voucherOrder.setVoucherId(voucherId); // 代金券id success = this.save(voucherOrder); if (!success) { // 创建秒杀券订单失败 throw new RuntimeException(\u0026#34;创建秒杀券订单失败！\u0026#34;); } // 返回订单id return Result.ok(voucherOrder.getId()); } } IVoucherOrderService /** * 优惠券订单Service */ public interface IVoucherOrderService extends IService\u0026lt;VoucherOrder\u0026gt; { /** * 秒杀下单优惠券 * @param voucherId 优惠券id * @return 下单id */ Result seckillVoucher(Long voucherId); /** * 判断是否是一人一单，如果是再去创建秒杀券订单 * @param userId 用户id * @param voucherId 订单id * @return 订单id */ Result createSecKillVoucherOrder(Long userId, Long voucherId); } 由于使用AOP实现动态代理模式，因此在pom中引入aspectj依赖。 \u0026lt;!-- aspectj --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.aspectj\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;aspectjweaver\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; 在启动类中添加@EnableAspectJAutoProxy(exposeProxy = true)注解，允许暴露代理对象，默认是关闭的。 @MapperScan(\u0026#34;com.hmdp.mapper\u0026#34;) @SpringBootApplication @EnableTransactionManagement @EnableAspectJAutoProxy(exposeProxy = true) // 允许暴露代理对象（显式获取） public class HmDianPingApplication { public static void main(String[] args) { SpringApplication.run(HmDianPingApplication.class, args); } } 还原数据库，再次使用JMeter测试一人一单问题，发现相同优惠券同一个用户最多只能下一单。 ![](Java/Java Web/Redis/files/7ec145186b9f03ff025e6362d1183e35_MD5.png)\n![](Java/Java Web/Redis/files/4425623de093fa79a0bfdb9be22eabf3_MD5.png)\n本次通过悲观锁解决了单体服务下一人一单超卖问题，通过特殊的锁对象userId，减少了锁定资源的范围，从一定程度上提高了性能！\n==总结: 单体项目下一人一单总的思想是 : 一个用户只能有一个线程处理(集群服务模式下也是一个用户只能有一个线程(包含所有集群的线程)处理.), 因此可以直接锁住用户id即可.== 7、集群服务下一人一单超卖问题\r思想: 一个用户一个线程 （1）搭建服务集群并实现负载均衡\r首先，在IDEA中启动两个SpringBoot程序，一个端口号是8081，另一个端口是8082（指定VM参数-Dserver.port=8082），模拟服务集群。\n![](Java/Java Web/Redis/files/ab6620f20e85b57c9324c33aca040a85_MD5.png)\n![](Java/Java Web/Redis/files/4120a1698c3444a4dcf27bb6682d18d7_MD5.png)\n在Nginx中配置负载均衡：\n![](Java/Java Web/Redis/files/8c60c3937bdc151b27345edc0a32c45e_MD5.png)\n保存nginx.conf文件，重启nginx：nginx -s reload。\n浏览器访问Nginx服务器（8080端口），nginx监听到8080带/api的请求，就会反向代理到backend，backend就会负载均衡到8081和8082端口。\n（2）一人一单的并发安全问题\r准备两个接口，两个接口的authorization相同确保两个接口是同一个用户发出，用于模拟集群下的用户重复下单。\n![](Java/Java Web/Redis/files/1a814bb069864f94987702c950a77f6f_MD5.png)\n这里虽然两个服务拿到的userId相同，但是仔细观察引用地址已经不再相同。锁不住查询订单都是0，造成集群环境下的一人一单超卖问题。\n![](Java/Java Web/Redis/files/bfefdf5de7a1dee3344dbb84bbff7261_MD5.png)\n有关锁失效的原因分析 synchronized是本地锁，只能保证单个JVM内部多个线程之间的互斥。由于现在我们部署了多个tomcat节点，每个tomcat都有一个属于自己的JVM，每个JVM都有自己的堆、栈、方法区和常量池。每个JVM都有一把synchronized锁，在JVM内部是一个锁监视器，多个JVM就有多个锁监视器，导致每一个锁都可以有一个线程获取，于是从原来的本地互斥锁变成了并行执行，就会发送并发安全问题。这就是在集群环境或分布式系统下，synchronized锁失效的原因，在这种情况下，我们就需要使用分布式锁（跨JVM锁、跨进程锁）来解决这个问题，让多个JVM只能使用同一把锁。\n![](Java/Java Web/Redis/files/5bde7e5d456d96f0f5b2852975a44d54_MD5.png)\n8、分布式锁介绍\r前面synchronized锁失效的原因是由于每一个JVM都有一个独立的锁监视器，用于监视当前JVM中的synchronized锁，所以无法保障多个集群下只有一个线程访问一个代码块。所以我们直接将使用一个分布式锁，在整个系统的全局中设置一个锁监视器，从而保障不同节点的JVM都能够识别，从而实现集群下只允许一个线程访问一个代码块。\n![](Java/Java Web/Redis/files/71a19aefca2c2b7adb6833c775e07841_MD5.png)\n分布式锁：满足分布式系统或集群模式下多进程可见并且互斥的锁。 分布式锁的核心思想就是让大家都使用同一把锁，只要大家使用的是同一把锁，那么我们就能锁住线程，不让线程进行，让程序串行执行，这就是分布式锁的核心思路。\n![](Java/Java Web/Redis/files/e2d3fe297312b4f190ab420d808edcfd_MD5.png)\n分布式锁的特点： 多线程可见性：多个线程都能看到相同的结果，多个进程之间都能感知到变化 互斥：互斥是分布式锁的最基本的条件，分布式锁必须能够确保在任何时刻只有一个节点能够获得锁，其他节点需要等待。 高可用：分布式锁应该具备高可用性，即使在网络分区或节点故障的情况下，程序不易崩溃，仍然能够正常工作。（容错性）当持有锁的节点发生故障或宕机时，系统需要能够自动释放该锁，以确保其他节点能够继续获取锁。 高性能：由于加锁本身就让性能降低，对于分布式锁需要较高的加锁性能和释放锁性能，尽可能减少对共享资源的访问等待时间，以及减少锁竞争带来的开销。 安全性：（可重入性）如果一个节点已经获得了锁，那么它可以继续请求获取该锁而不会造成死锁。（锁超时机制）为了避免某个节点因故障或其他原因无限期持有锁而影响系统正常运行，分布式锁通常应该设置超时机制，确保锁的自动释放。 ![](Java/Java Web/Redis/files/9e086352b12d40933e48c1b28961d0b4_MD5.png)\n分布式锁的常见实现方式： ![](Java/Java Web/Redis/files/8285583684e852f60ae556e7162fbbed_MD5.png)\n基于关系数据库：可以利用数据库的事务特性和唯一索引来实现分布式锁。通过向数据库插入一条具有唯一约束的记录作为锁，其他进程在获取锁时会受到数据库的并发控制机制限制。 基于缓存（如Redis）：使用分布式缓存服务（如Redis）提供的原子操作来实现分布式锁。通过将锁信息存储在缓存中，其他进程可以通过检查缓存中的锁状态来判断是否可以获取锁。 基于ZooKeeper：ZooKeeper是一个分布式协调服务，可以用于实现分布式锁。通过创建临时有序节点，每个请求都会尝试创建一个唯一的节点，并检查自己是否是最小节点，如果是，则表示获取到了锁。 基于分布式算法：还可以利用一些分布式算法来实现分布式锁，例如Chubby、DLM（Distributed Lock Manager）等。这些算法通过在分布式系统中协调进程之间的通信和状态变化，实现分布式锁的功能。 9、Redis分布式锁解决集群超卖问题\r由于本项目是专门学习Redis的，所以在这里将会使用Redis的setnx指令实现分布式锁解决超卖问题。\nSETNX命令特点（互斥）：只能设置key不存在的值，值不存在设置成功，返回1；值存在设置失败，返回0；\nSET key value EX seconds NX：相当于SETNX命令，成功返回ok，失败返回nil，但是该命令可以保证setnx和expire两个命令的原子性，要么同时成功，要么同时失败。防止在执行完setnx后Redis服务宕机，还没来得及执行expire设置过期时间的情况，保证了分布式锁的安全性。\n获取分布式锁 # 添加锁，利用setnx互斥的特性 SETNX key value # 为锁设置过期时间，超时释放，避免死锁 EXPIRE key time # 为了保证上面两条命令的原子性，使用下面的命令获取锁 SET key value EX seconds NX 释放分布式锁 # 手动释放（还可设置过期时间，超时剔除释放锁） DEL key 获取锁失败后，重试获取锁有两种机制，阻塞式获取和非阻塞式获取：\n阻塞锁：没有获取到锁，则继续等待获取锁。浪费CPU，线程等待时间较长，实现较麻烦。 非阻塞锁：尝试一次，没有获取到锁后，不继续等待，直接返回锁失败。（本次采用非阻塞机制） ![](Java/Java Web/Redis/files/c816a150bdeeed3123db7551969e5744_MD5.png)\n创建分布式锁 /** * 锁接口 */ public interface ILock { /** * 尝试获取锁 * @param timeoutSec 锁持有的超时时间，过期后自动释放 * @return true代表获取锁成功; false代表获取锁失败 */ boolean tryLock(long timeoutSec); /** * 释放锁 */ void unlock(); } /** * Redis分布式锁（版本一） */ public class SimpleRedisLock implements ILock { // 锁key的业务名称 private String name; private StringRedisTemplate stringRedisTemplate; // 锁统一前缀 private static final String KEY_PREFIX = \u0026#34;lock:\u0026#34;; public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) { this.name = name; this.stringRedisTemplate = stringRedisTemplate; } /** * 尝试获取锁 * @param timeoutSec 锁持有的超时时间，过期后自动释放 * @return true代表获取锁成功; false代表获取锁失败 */ @Override public boolean tryLock(long timeoutSec) { String threadId = String.valueOf(Thread.currentThread().getId()); // 获取线程id作为value Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS); return Boolean.TRUE.equals(success); } /** * 释放锁 */ @Override public void unlock() { stringRedisTemplate.delete(KEY_PREFIX + name); } } 使用分布式锁改造前面VoucherOrderServiceImpl中的代码，将之前使用synchronized锁的地方，改成我们自己实现的分布式锁 ==下面使用 KEY_PREFIX+SECKILL_VOUCHER_ORDER+userId 作为key, thread作为value. 使用userid作为key主要是为了限制一人一单, 我们可以将所有机器的线程一并看待, 这些线程如果要限制一人一单, 最好使用user相关的属性作为key, 而value用来表示所有线程中哪个线程正在为这个userId服务, 当某个线程要为userID服务时,先看一下redis中有没有这个键值对(userId:threadID), 有说明其他线程已经开始创建订单了, 因为是一人一单, 所以本线程放弃即可== 这种实现显然是不可重入的, {userID: threadID}, 压根没有冲入次数 @Resource private StringRedisTemplate stringRedisTemplate; /** * 秒杀下单优惠券 * @param voucherId 优惠券id * @return 下单id */ @Override public Result seckillVoucher(Long voucherId) { // 查询秒杀优惠券信息 SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId); // 判断秒杀是否开始 if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) { // 秒杀活动尚未开始 return Result.fail(\u0026#34;秒杀尚未开始！\u0026#34;); } // 判断秒杀是否结束 if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) { // 秒杀活动已经结束 return Result.fail(\u0026#34;秒杀已经结束！\u0026#34;); } // 判断库存是否充足 if (seckillVoucher.getStock() \u0026lt; 1) { // 秒杀券库存不足 return Result.fail(\u0026#34;秒杀券已抢空！\u0026#34;); } // 判断是否是一人一单，如果是再去下订单 Long userId = ((UserVo) BaseContext.get()).getId(); // 创建锁对象 SimpleRedisLock lock = new SimpleRedisLock(SECKILL_VOUCHER_ORDER + userId, stringRedisTemplate); // 尝试获取锁 boolean isLock = lock.tryLock(10L); // 获取锁失败 if (!isLock) { // 获取锁失败，返回错误信息或重试 return Result.fail(\u0026#34;不允许重复下单，一个人只允许下一单\u0026#34;); } try { // 使用AopContext.currentProxy()方法拿到当前目标对象的代理对象 IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy(); // 使用带有事务功能的代理对象去调用 return proxy.createSecKillVoucherOrder(userId, voucherId); } finally { // 释放锁 lock.unlock(); } } 因为锁的是用户id，即同一个用户不能同时下单多次，所以相同用户再次下单获取锁会失败，并且不会再次重试，直接返回错误信息。\n测试效果 恢复数据库并启动两台SpringBoot服务集群，在ApiFox中使用同一个用户的token发送两次请求，可以发现同一个用户只能成功获取一次锁，实现了Redis分布式锁的互斥效果，解决了一人一单集群超卖问题。\n![](Java/Java Web/Redis/files/50e6743d8158835eaf46865d3ccc4b4f_MD5.png)\n![](Java/Java Web/Redis/files/405c6f369f3763d2f98e88c8d553972f_MD5.png)\n10、Redis分布式锁优化\r（1）Redis分布式锁超时误删问题\r上一节，我们实现了一个简单的分布式锁，但是会存在一个问题： ![](Java/Java Web/Redis/files/e9e660342e7f629e0b89bbc914e28254_MD5.png)\n逻辑说明： 首先线程1获取到锁，持有锁的线程1由于业务复杂或业务异常，出现了业务阻塞，而锁的过期时间少于业务完成时间，导致线程1的锁自动释放。这时线程2来尝试获得锁，就拿到了这把锁，然后线程2在持有锁执行过程中，线程1继续执行业务执行完毕，准备释放锁，此时就会把本应该属于线程2的锁进行删除，这就是误删其他线程锁的问题。\n解决方案： 在每个线程释放锁的时候，去判断一下当前这把锁是否属于自己，如果属于自己，则进行锁的删除；如果不属于自己，则不进行释放锁逻辑。我们之前是使用当前获取锁的线程id作为锁的标识，但是在多个JVM内部，因为线程id是递增分配的，可能会出现线程id重复的情况。因此我们在线程id前面添加一个UUID，用于区分不同的JVM，而线程id用于区分同一个JVM内部的不同请求。这样就保证了分布式锁的标识唯一。\n（2）解决Redis分布式锁超时误删问题\r本次优化主要解决了锁超时自动释放出现的超卖问题\n![](Java/Java Web/Redis/files/6431aa95b3517bedfb7af93f62dd1d51_MD5.png)\nSimpleRedisLock /** * Redis分布式锁 */ public class SimpleRedisLock implements ILock { // 锁key的业务名称 private String name; private StringRedisTemplate stringRedisTemplate; // 锁key的统一前缀 private static final String KEY_PREFIX = \u0026#34;lock:\u0026#34;; // 锁value = \u0026#34;UUID-ThreadId\u0026#34;，ID_PREFIX用于区分不同JVM，线程唯一标识用于区分不同服务 private static final String ID_PREFIX = UUID.randomUUID().toString(true) + \u0026#34;-\u0026#34;; public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) { this.name = name; this.stringRedisTemplate = stringRedisTemplate; } /** * 尝试获取锁 * @param timeoutSec 锁持有的超时时间，过期后自动释放 * @return true代表获取锁成功; false代表获取锁失败 */ @Override public boolean tryLock(long timeoutSec) { // 获取当前JVM内部的当前线程id String threadId = ID_PREFIX + Thread.currentThread().getId(); // 锁value = \u0026#34;UUID-ThreadId\u0026#34; // 尝试获取锁 Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS); // 拆箱判断 return Boolean.TRUE.equals(success); } /** * 释放锁 */ @Override public void unlock() { // 获取当前线程标识 String threadId = ID_PREFIX + Thread.currentThread().getId(); // 获取锁的线程标识 String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name); // 判断标识是否一致 if (threadId.equals(id)) { // 如果一致，释放锁，否则什么都不做 stringRedisTemplate.delete(KEY_PREFIX + name); } } } （3）Redis分布式锁释放锁原子性问题\r问题分析 在上一节中，我们通过给锁添加一个线程唯一标识，并且在释放锁时添加一个判断，从而防止锁超时自动释放的问题，但是仍然存在超卖问题：\n![](Java/Java Web/Redis/files/629b2062682a549cfe92d6f4c413ecb7_MD5.png)\n当线程1获取到锁并且执行完业务后，判断完当前锁是自己的锁正准备释放锁时，由于JVM的垃圾回收机制导致短暂的阻塞发生了阻塞，恰好在阻塞期间锁被超时释放了。线程2获得锁执行业务，但就在此时线程1阻塞完成，由于已经判断过锁标识，已经确定锁是自己的锁了，于是直接删除了锁。而这时删的是线程2的锁，没有了锁的互斥，线程3再来了之后就会发生超卖问题。\n原因分析 因为判断锁标识和释放锁的这两个操作不是真的原子性，而是在java代码中判断的，在这两个操作之前虽然没有任何Java代码，但是由于JVM中的垃圾回收机制Full GC的存在，就有可能出现阻塞问题（概率非常低）。\n解决方案 所以为了解决这个问题，必须要保证判断锁标识和释放锁这两个动作是一个原子性操作。因此我们需要使用Lua脚本。\n（4）解决Redis分布式锁释放锁原子性问题\r本次优化主要解决了释放锁时的原子性问题（本质还是锁超时误删问题）。\nRedis提供了Lua脚本功能，在一个脚本中编写多条Redis命令，确保多条命令执行时的原子性。Lua是一种编程语言，基本语法参考菜鸟教程：https://www.runoob.com/lua/lua-tutorial.html\n这里重点介绍Redis提供的调用函数，语法如下：\n# 执行Redis命令 redis.call(\u0026#39;命令名称\u0026#39;, \u0026#39;key\u0026#39;, \u0026#39;其他参数\u0026#39;, ...) 例如，我们要执行set name jack，则脚本是这样：\n# 执行 set name jack redis.call(\u0026#39;set\u0026#39;, \u0026#39;name\u0026#39;, \u0026#39;jack\u0026#39;) 例如，我们要先执行set name jack，再执行get name，则脚本如下：\n# 先执行 set name jack redis.call(\u0026#39;set\u0026#39;, \u0026#39;name\u0026#39;, \u0026#39;jack\u0026#39;) # 再执行 get name local name = redis.call(\u0026#39;get\u0026#39;, \u0026#39;name\u0026#39;) # 返回 return name 写好脚本以后，需要用Redis命令来调用脚本，调用脚本的命令用法如下：\n![](Java/Java Web/Redis/files/0a8bda61745c77f7e409a7684e8af1cc_MD5.png)\n例如，我们要执行redis.call('set', 'name', 'jack')这个脚本，语法如下：\n![](Java/Java Web/Redis/files/8a37eb31de23542daef843ba0cdfe7dc_MD5.png)\n如果脚本中的key、value不想写死，可以作为参数传递。key类型参数会放入KEYS数组，其它参数会放入ARGV数组，在脚本中可以从KEYS和ARGV数组获取这些参数：\n![](Java/Java Web/Redis/files/4ced7a44b60e26a07a26fbb2a87ed89e_MD5.png)\n优化分布式锁 ![](Java/Java Web/Redis/files/9c05daf92e7b54f20c0f7a15935f9ec0_MD5.png)\nIDEA中编写Lua脚本语法高亮提示插件：EmmyLua（安装第二个Luanalysis也可以）\n![](Java/Java Web/Redis/files/74266b9bfd890a4bafe3cd4ef0480f58_MD5.png)\n释放锁的业务流程是这样的：\n获取锁中的线程标示 判断是否与指定的标示（当前线程标示）一致 如果一致则释放锁（删除） 如果不一致则什么都不做 编写释放锁的Lua脚本：unlock.lua -- 锁的key local key = KEYS[1] -- 当前线程标识 local threadId = ARGV[1] -- 获取锁中的线程标识 local id = redis.call(\u0026#39;get\u0026#39;, key) -- 比较当前线程标识是否和锁中的线程标识一致 if (id == threadId) then -- 一致，释放锁 return redis.call(\u0026#39;del\u0026#39;, key) end -- 不一致，返回0，表示释放锁失败 return 0 SimpleRedisLock /** * Redis分布式锁 */ public class SimpleRedisLock implements ILock { // 锁key的业务名称 private String name; private StringRedisTemplate stringRedisTemplate; // 锁key的统一前缀 private static final String KEY_PREFIX = \u0026#34;lock:\u0026#34;; // 锁value = \u0026#34;UUID-ThreadId\u0026#34;，ID_PREFIX用于区分不同JVM，线程唯一标识用于区分不同服务 // 因为这里锁是final的静态常量，仅在项目启动时随着该类加载，而去初始化该变量，UUID只会被初始化一次，所以当前服务器JVM拿到的ID_PREFIX都一样 private static final String ID_PREFIX = UUID.randomUUID().toString(true) + \u0026#34;-\u0026#34;; private static final String ID_PREFIX = UUID.randomUUID().toString(true) + \u0026#34;-\u0026#34;; // RedisScript接口实现类 private static final DefaultRedisScript\u0026lt;Long\u0026gt; UNLOCK_SCRIPT; // 静态代码块初始化加载脚本 static { UNLOCK_SCRIPT = new DefaultRedisScript\u0026lt;\u0026gt;(); UNLOCK_SCRIPT.setLocation(new ClassPathResource(\u0026#34;script/unlock.lua\u0026#34;)); UNLOCK_SCRIPT.setResultType(Long.class); } public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) { this.name = name; this.stringRedisTemplate = stringRedisTemplate; } /** * 尝试获取锁 * @param timeoutSec 锁持有的超时时间，过期后自动释放 * @return true代表获取锁成功; false代表获取锁失败 */ @Override public boolean tryLock(long timeoutSec) { // 获取当前JVM内部的当前线程id String threadId = ID_PREFIX + Thread.currentThread().getId(); // 锁value = \u0026#34;UUID-ThreadId\u0026#34; // 尝试获取锁 Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS); // 拆箱判断 return Boolean.TRUE.equals(success); } /** * 调用lua脚本释放锁 */ @Override public void unlock() { // Redis调用Lua脚本 stringRedisTemplate.execute( UNLOCK_SCRIPT, Collections.singletonList(KEY_PREFIX + name), ID_PREFIX + Thread.currentThread().getId() ); } } 测试效果 同时启动两台tomcat服务，用ApiFox模拟同一个用户同时重复下单两次请求。假设线程1先获取到锁\n![](Java/Java Web/Redis/files/ae635c5c94b09919b47c36418f7683b6_MD5.png)\n线程1的锁线程标识为：2264fef5b6544e8f889bb209cba9569a-50，此时删除线程1的锁，模拟锁超时释放\n![](Java/Java Web/Redis/files/43c3cb444f1f189a9174fa55676c2c33_MD5.png)\n由于线程1的锁已经被超时释放，线程2现在也可以获取到锁\n![](Java/Java Web/Redis/files/0dea26a24ff5b50bc5efddc809a002cc_MD5.png)\n线程2的锁线程标识为：9fb0b47e16bb4a2791c87085e00014a3-54\n![](Java/Java Web/Redis/files/ec7a5a393b751373d8fa5c886a287737_MD5.png)\n此时线程1执行完业务，尝试释放锁，由于现在锁是线程2的，线程1判断线程标识不同后，不会进行锁误删。线程2仍然可以在锁内正常执行业务。\n![](Java/Java Web/Redis/files/9865c8061f3fbb678cd48a109f52472e_MD5.png)\n当线程2执行完业务并释放锁后，锁才会正常被删除，并且通过Lua脚本保证了判断锁标识和删除锁两个操作的原子性，不再受到JVM垃圾回收机制的干扰，进一步避免了锁超时后的误删问题。\n现在我们的分布式锁满足了以下特性：\n多线程可见，将锁放到Redis中，相当于全局的锁监视器，所有的JVM都可以同时看到 互斥，set ex nx指令互斥 高可用，层层优化，即使是特别极端的情况下照样可以防止超卖，后期Redis还可以扩展搭建主从集群 高性能，Redis的IO速度很快，Lua脚本的性能也很快 安全性，通过给锁设置当前线程标识+Lua封装Redis指令，充分保障了线程安全，同时采用超时释放避免死锁 基于Redis的分布式锁实现思路：\n利用set ex nx获取锁，并设置过期时间，保存线程标识 释放锁时先判断线程标识是否与自己一致，一致则删除锁 11、Redisson\r经过对我们自定义实现的Redis分布式锁的优化后，已经达到生产可用级别了，但是还不够完善，比如： ![](Java/Java Web/Redis/files/7eff1abd425e71f6015b741141ca4b98_MD5.png)\n不可重入：同一线程不能重复获取同一把锁。比如，方法A中调用方法B，在方法A中执行业务并获取锁，方法B需要获取同一把锁，如果锁是不可重入的，在方法A获取了锁，方法B无法再次获取这把锁，方法B此时就会等待方法A的锁释放，而方法A还没有执行完，因为还在调方法B，导致死锁。这种场景下要求锁是可重入的，可重入锁的意义在于防止死锁，我们的synchronized和Lock锁都是可重入的。 不可重试：我们之前实现的锁是非阻塞式，获取锁只尝试一次就返回false，没有重试机制。有些业务场景在获取锁失败后，需要等待一小段时间，再次进行重试的（阻塞式、可重试的）。 超时释放：虽然我们之前利用判断锁标识+Lua脚本解决了因为锁超时释放导致的误删问题，但是还是存在超时释放的时间问题。 如果业务执行耗时过长，期间锁就释放了，这样存在安全隐患。 如果锁的有效期过短，容易出现业务没执行完就被释放，这样存在并发安全问题。 如果锁的有效期过长，容易出现锁的阻塞周期过长问题。 主从一致性问题：如果Redis提供了主从集群（相当于读写分离，写操作访问主节点，读操作访问从节点），主节点需要把自己的数据同步给从节点，保证主从数据一致，如果主节点宕机，还可以选择一个从节点成为新的主节点。但是主从同步之间存在延迟，比如在极端情况下，线程在主节点获取了锁，写操作在主节点完成后尚未同步给从节点时，主节点宕机，此时会选一个新的从作为主，而从节点没有完成数据同步，没有锁的标识，此时多个从节点就会获取到锁，存在安全隐患。 我们如果想要更进一步优化分布式锁，当然是可以的，但是没必要，我们完全可以直接使用已经造好的轮子，比如：Redssion\n（1）介绍\r官方定义：Redisson是一个在Redis的基础上实现的Java驻内存数据网格（In-Memory Data Grid）。\n简单来讲Redisson是一个在Redis的基础上实现的分布式工具的集合。它不仅提供了一系列的分布式的Java常用对象，还提供了许多分布式服务，其中就包含了各种分布式锁的实现。\nRedission提供了分布式锁的多种多样的功能\n![](Java/Java Web/Redis/files/1d2fffcda2a84dafc1893dce3bb18451_MD5.png)\n官网地址：https://redisson.org\nGitHub地址：https://github.com/redisson/redisson\nRedisson帮助文档：https://redisson.org/docs/\n（2）Redisson实现分布式锁\r引入redisson依赖 \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.redisson\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;redisson\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.37.0\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; 温馨提示：此外还有一种引入方式，直接引入redission的starter依赖，然后在yam|文件中配置Redisson，但是不推荐这种方式，因为他会替换掉Spring官方提供的这套对 Redisson 的配置。所以我们采用@Bean手动配置。\nRedissonConfig：配置Redisson客户端 /** * Redisson配置类 */ @Configuration public class RedissonConfig { @Bean public RedissonClient redissonClient() { // 配置类 Config config = new Config(); // 添加redis地址，这里添加了单点的地址，也可以使用config.useClusterServers()添加集群地址 config.useSingleServer().setAddress(\u0026#34;redis://192.168.8.100:6379\u0026#34;).setPassword(\u0026#34;123321\u0026#34;); // 创建RedissonClient客户端对象 return Redisson.create(config); } } VoucherOrderServiceImpl：只需要修改使用锁的地方，将我们自己实现的分布式锁SimpleRedisLock替换成Redisson，其它的业务代码无需修改 @Resource private RedissonClient redissonClient; /** * 秒杀下单优惠券 * @param voucherId 优惠券id * @return 下单id */ @Override public Result seckillVoucher(Long voucherId) { // 业务校验省略... // 判断是否是一人一单，如果是再去下订单 Long userId = ((UserVo) BaseContext.get()).getId(); // 创建锁对象(可重入)，指定锁的名称 RLock lock = redissonClient.getLock(LOCK_KEY_PREFIX + SECKILL_VOUCHER_ORDER + userId); // 尝试获取锁，参数分别是：获取锁的最大等待时间(期间会重试)，锁自动释放时间，时间单位 // 空参表示默认参数，获取锁的最大等待时间waitTime为-1，表示获取失败不等待不重试，直接返回结果；锁自动释放时间leaseTime为30秒，表示超过30秒还没有释放的话会自动释放锁 boolean isLock = lock.tryLock(); // 空参默认失败不等待 // 获取锁失败 if (!isLock) { // 获取锁失败，返回错误信息或重试（该业务是一人一单，直接返回失败信息，不重试） return Result.fail(\u0026#34;不允许重复下单，一个人只允许下一单\u0026#34;); } try { // 使用AopContext.currentProxy()方法拿到当前目标对象的代理对象 IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy(); // 使用带有事务功能的代理对象去调用 return proxy.createSecKillVoucherOrder(userId, voucherId); } finally { // 释放锁 lock.unlock(); } } tryLock 方法介绍 tryLock()：它会使用默认的超时时间（默认30秒）和等待机制。具体的超时时间是由 Redisson 配置文件或者自定义配置决定的。 tryLock(long time，TimeUnit unit)：它会在指定的时间内尝试获取锁（等待time后重试），如果获取成功则返回 true，表示获取到了锁；如果在指定时间内（Redisson内部默认指定的）未能获取到锁，则返回false。 tryLock(long waitTime，long leaseTime，TimeUnit unit)：waitTime是获取锁失败后的重试等待时间，等待时间过了之后会接着重试，例如等待时间为一秒，那获取锁失败等一秒后再次尝试获取锁，在超时时间内反复重试，直到获取锁成功后返回true。leaseTime 是锁的超时时间，如果超过 leaseTime 后还没有获取锁就直接返回false。 总之tryLock的灵活性逐渐提高，无参tryLock()时，waitTime的默认值是-1，代表不等待，leaseTime的默认值是30，unit默认值是 seconds ，也就是锁超过30秒还没有释放就自动释放。\n（3）Redisson分布式锁原理\r什么是重入锁和不可重入锁？ 可重入锁：又称之为递归锁，也就是一个线程可以反复获取锁多次，一个线程获取到锁之后，内部如果还需要获取锁，可以直接再获取锁，前提是同一个对象或者class。可重入锁的最重要作用就是避免死锁的情况。 不可重入锁：又称之为自旋锁，底层是一个循环加上unsafe和cas机制，就是一直循环直到抢到锁，这个过程通过cas进行限制，如果一个线程获取到锁，cas会返回1，其它线程包括自己就不能再持有锁，需要等线程释放锁。 ① 可重入锁的原理\rReentrantLock和synchronized都是可重入锁。在ReentrantLock锁中，底层借助于一个voaltile的state变量来记录重入状态的次数的，所以允许一个线程多次获取资源锁。第一次调用lock时，计数设置为1，再次获取资源锁时加1，调用unlock解锁，计数减1，直到减为0，释放锁资源。在synchronized锁中，它在c语言代码中会有一个count，原理和state类似，也是重入一次就+1，释放一次就-1 ，直到减少成 0 时，表示当前这把锁没有被持有。\n![](Java/Java Web/Redis/files/f575ae525d874745ebe327dd79ee1730_MD5.png)\n我们之前的自定义的分布式锁不具有可重入性的原因，是因为：重入锁的设计必须要求既记录线程标识，又要记录重入次数，而我们String数据类型的锁已经不够用了。因此，需要一个key里同时记录两个字段的情况，可以使用hash数据结构。\n![](Java/Java Web/Redis/files/bd74ddacba2cfad889bf12b328bcf48f_MD5.png)\n而Redisson底层也是以 hash 数据结构的形式将锁存储在Redis中，并且Redisson分布式锁也具有可重入性，每次获取锁，都将 value 的值+1，每次释放锁，都将 value 的值-1，只有锁的 value 值归0时才会真正的释放锁，从而确保锁的可重入性。\n![](Java/Java Web/Redis/files/8828c4722fa4f36723a31ff15833629e_MD5.png)\n测试Redisson锁的可重入性 @SpringBootTest @Slf4j public class RedissonLockTest { @Resource private RedissonClient redissonClient; // 创建锁对象 private RLock lock = redissonClient.getLock(\u0026#34;lock\u0026#34;); /** * 方法1 获取一次锁 */ @Test void method1() { boolean isLock = false; try { // 尝试获取锁 isLock = lock.tryLock(); if (!isLock) { log.error(\u0026#34;1...获取锁失败\u0026#34;); return; } log.info(\u0026#34;1...获取锁成功\u0026#34;); // 方法1内部调用方法2 log.info(\u0026#34;1...开始执行并发业务\u0026#34;); method2(); log.info(\u0026#34;1...继续执行剩余的并发业务\u0026#34;); } finally { if (isLock) { log.warn(\u0026#34;1...释放锁\u0026#34;); lock.unlock(); } } } /** * 方法2 再获取一次锁 */ void method2() { log.info(\u0026#34;2...业务方法1调用执行业务方法2\u0026#34;); boolean isLock = false; try { // 再次尝试获取锁 isLock = lock.tryLock(); if (!isLock) { log.error(\u0026#34;2...获取锁失败\u0026#34;); return; } log.info(\u0026#34;2...获取锁成功\u0026#34;); log.info(\u0026#34;2...开始执行业务\u0026#34;); } finally { if (isLock) { log.warn(\u0026#34;2...释放锁\u0026#34;); lock.unlock(); } } } } 执行流程如下 ![](Java/Java Web/Redis/files/976719c1dae6010cbd1f095781a9fed4_MD5.png)\n![](Java/Java Web/Redis/files/66c5c4f60c1c0f9808a4efd2e819f721_MD5.png)\n![](Java/Java Web/Redis/files/147827f0698898ea794e2c4f7fd10132_MD5.png)\n![](Java/Java Web/Redis/files/4753f9b8e1ba5b935831496d1c1baf27_MD5.png)\n![](Java/Java Web/Redis/files/51956161a9cbe0473b5b47ed1d27ba84_MD5.png)\n② Redisson源码流程原理解析\r之前我们分析发现，自己实现的锁不够灵活，具有不可重入、不可重试、超时释放、主从一致性四大问题。虽然我们对自定义的Redis分布式锁进行了优化，但是不如直接使用别人造好的轮子，Redisson分布式锁不仅提供了多种锁实现，而且还解决了分布式锁不够灵活的这四大问题，下面我们通过深入剖析Redisson源码，看看底层是如何实现和解决：锁的可重入性、可重试机制、超时续约机制和主从一致性问题的。\n1）Redisson的可重入锁原理\n因为在之前我们使用String类型的setnx命令可以保证获取锁的原子性，释放锁的原子性使用了Lua脚本。但是我们为了保证锁的可重入性，需要使用Hash结构来存储线程标识和重入次数这两个字段，而Hash类型中并没有这种组合命令保证原子性。所以下面我们来分析一下，Redisson的获取锁 tryLock 和释放锁 unlock 方法的底层是如何实现这种重入性，并且保证命令的原子性的？\ntryLock源码 首先跟踪一下tryLock的源码\n![](Java/Java Web/Redis/files/cc7a7d542e46b3656ce4512596b644fa_MD5.png)\n空参调用的是Lock接口的空参方法，有参调用的是RLock接口的有参方法，不管是哪种调用，我们选择创建锁对象的实现类都是RedissonLock\n![](Java/Java Web/Redis/files/0203f0c4126e6c64f7d9d481e7d9e25f_MD5.png)\n![](Java/Java Web/Redis/files/851dc1a4cc71bc086d08d92c5821989e_MD5.png)\n我们从空参和有参的方法同步进行分析，最后它们都会执行到同一个获取锁方法。\n![](Java/Java Web/Redis/files/c06f4aac38e5e103f617bbce95896505_MD5.png)\n空参tryLock调用tryLockAsync方法内部传入的waitTime和leaseTime的默认值都是-1，调用tryAcquireOnceAsync方法作为参数。\n![](Java/Java Web/Redis/files/1d801c113ea6b48b5531c57587e1c627_MD5.png)\n有参tryLock经过传递调用，最后也同样会执行tryAcquireOnceAsync方法。\n![](Java/Java Web/Redis/files/31a1abec56e8f441e10d6ca83335fe3f_MD5.png)\n在tryAcquireOnceAsync方法内部，如果过期时间leaseTime \u0026gt; 0，说明调用trylockInnerAsync方法，该方法最终也是调用Lua脚本保证命令原子性，尝试获取锁的脚本逻辑如下。\n![](Java/Java Web/Redis/files/443acc325a4c25516b20482ba2fe1257_MD5.png)\nunlock源码 跟踪unlock接口的抽象实现类RedissonBaseLock。\n![](Java/Java Web/Redis/files/fbc143412bf389fb7055fcff6612d60c_MD5.png)\n经过层层调用，最终释放锁的实现类还是RedissonLock。\n![](Java/Java Web/Redis/files/40a3f96a5790d7a71cd04e16c111ba4d_MD5.png)\n![](Java/Java Web/Redis/files/4c27c3871b38a59572e9bd4f1beebcbf_MD5.png)\n实现类RedissonLock对父类的unlockInnerAsync抽象方法进行了重写实现，可以看到，内部还是使用Lua脚本去构建和执行释放锁的逻辑。\n![](Java/Java Web/Redis/files/bee446c65293e0ecfba9a787eea4696b_MD5.png)\n由于这段脚本中KEYS和ARGV参数的可读性不强，所以这里单独提取出来，方便阅读。释放锁的脚本逻辑如下：\n--- 新版Redisson释放锁Lua脚本 local lockKey = KEYS[1]; -- 锁的key local channelName = KEYS[2]; -- 频道名称 local unlockLatchNameByRequestId = KEYS[3]; -- 释放锁请求id local unlockMessage = ARGV[1]; -- 释放锁消息 local leaseTime = ARGV[2]; -- 锁的有效期 local threadId = ARGV[3]; -- 线程唯一标识 local publishCommand = ARGV[4]; -- 发布消息命令 local timeout = ARGV[5]; -- 超时时间 -- 获取解锁请求标志位的值 local val = redis.call(\u0026#39;get\u0026#39;, unlockLatchNameByRequestId); -- 如果标志位存在且不为false，直接返回其数值 if val ~= false then return tonumber(val); end; -- 检查锁是否是当前线程所属的 if (redis.call(\u0026#39;hexists\u0026#39;, lockKey, threadId) == 0) then -- 如果锁不属于当前线程，则不能释放其他线程的锁，返回nil return nil; end; -- 在释放锁之前，先将当前线程锁的重入计数器减一 local counter = redis.call(\u0026#39;hincrby\u0026#39;, lockKey, threadId, -1); -- 再判断计数器是否大于0，如果重入次数大于0，说明锁的业务不在最外层，因此不能释放锁 if (counter \u0026gt; 0) then -- 重置锁的过期时间 redis.call(\u0026#39;pexpire\u0026#39;, lockKey, leaseTime); -- 设置释放锁请求标志位为0，并设置超时时间 redis.call(\u0026#39;set\u0026#39;, unlockLatchNameByRequestId, 0, \u0026#39;px\u0026#39;, timeout); -- 释放锁失败，仅将重入计数器-1后返回0 return 0; else -- 如果重入计数器等于0，说明该锁的最外层业务已执行完，可以释放锁 -- 删除锁 redis.call(\u0026#39;del\u0026#39;, lockKey); -- 向释放锁的频道发布锁已释放的消息，通知每一个订阅该频道的消费者 redis.call(publishCommand, channelName, unlockMessage); -- 设置释放锁请求标志位为1，并设置过期时间 redis.call(\u0026#39;set\u0026#39;, unlockLatchNameByRequestId, 1, \u0026#39;px\u0026#39;, timeout); -- 释放锁成功，返回1 return 1; end; 总结：Redisson锁重入也是利用hash结构记录线程id和重入次数。重入性的实现和可重入锁所使用的原理类似，并且通过Lua脚本保证命令执行的原子性。\n注意：本文分析的是Redisson 3.37.0最新版本源码，旧版源码实现方式可能有所不同，但大同小异，原理都是差不多的。\n2）Redisson的锁重试和WatchDog机制源码解析\n刚刚我们分析了我们自定义的分布式锁不可重入的原因，并且剖析了Redisson的tryLock和unlock的源码，知道了它解决锁不可重入问题的实现原理是：通过hash类型的锁结构的设计，存储当前线程标识和重入计数器，来控制当前线程的再次重入和重入锁释放锁。这和JDK里的ReentrantLock重入锁的实现原理基本一致。\n下面我们来看一下Redisson是如何解决不可重试、超时释放问题的。\n锁重试 redisson在尝试获取锁的时候，如果传了时间参数，就不会在获取锁失败时立即返回失败，而是会进行重试。三个参数：最大重试时间，锁释放时间（默认为-1，会触发看门狗机制），时间单位。\n源码分析： 在RLock接口中，找到三个参数的tryLock方法的实现，选择RedissonLock\n![](Java/Java Web/Redis/files/ac79e0c234c3a84ad6604f38905dfb9c_MD5.png)\ntryLock(long waitTime, long leaseTime, TimeUnit unit) 方法解读：\n@Override public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException { long time = unit.toMillis(waitTime); long current = System.currentTimeMillis(); long threadId = Thread.currentThread().getId(); // 尝试获取锁，返回的ttl为null，表示获取成功 Long ttl = tryAcquire(waitTime, leaseTime, unit, threadId); // lock获取成功 if (ttl == null) { return true; } // 获取失败，计算剩余时间 time -= System.currentTimeMillis() - current; // 剩余时间小于等于零，不再重试，返回失败 if (time \u0026lt;= 0) { acquireFailed(waitTime, unit, threadId); return false; } // 剩余时间大于零，重新获取当前时间current，subscribe订阅拿到锁的线程，该线程释放锁后会发布通知，其余等待的线程可以继续争抢。 current = System.currentTimeMillis(); // 创建一个用于订阅锁释放通知的subscribeFuture，调用subscribe()方法进行订阅 CompletableFuture\u0026lt;RedissonLockEntry\u0026gt; subscribeFuture = subscribe(threadId); try { // 等待time毫秒时间，等待订阅结果 subscribeFuture.get(time, TimeUnit.MILLISECONDS); } catch (TimeoutException e) { // 如果在等待期间未收到订阅结果，表示等待超时。在等待超时后，代码会尝试取消订阅任务 if (!subscribeFuture.completeExceptionally(new RedisTimeoutException( \u0026#34;Unable to acquire subscription lock after \u0026#34; + time + \u0026#34;ms. \u0026#34; + \u0026#34;Try to increase \u0026#39;subscriptionsPerConnection\u0026#39; and/or \u0026#39;subscriptionConnectionPoolSize\u0026#39; parameters.\u0026#34;))) { // 判断是否需要取消订阅，并调用unsubscribe()方法进行处理 subscribeFuture.whenComplete((res, ex) -\u0026gt; { if (ex == null) { unsubscribe(res, threadId); } }); } // 如果取消成功，则代码调用acquireFailed()方法进行处理，表示当前线程获取锁失败，最终返回 false acquireFailed(waitTime, unit, threadId); return false; } catch (ExecutionException e) { LOGGER.error(e.getMessage(), e); acquireFailed(waitTime, unit, threadId); return false; } // 等到了订阅中的贤臣释放锁, try { time -= System.currentTimeMillis() - current; if (time \u0026lt;= 0) { acquireFailed(waitTime, unit, threadId); return false; } while (true) { long currentTime = System.currentTimeMillis(); // 仍有等待时间，则进行获取锁重试 ttl = tryAcquire(waitTime, leaseTime, unit, threadId); // lock获取成功 if (ttl == null) { return true; } // // 剩余时间小于等于零，不再重试，返回失败 time -= System.currentTimeMillis() - currentTime; if (time \u0026lt;= 0) { acquireFailed(waitTime, unit, threadId); return false; } // waiting for message currentTime = System.currentTimeMillis(); if (ttl \u0026gt;= 0 \u0026amp;\u0026amp; ttl \u0026lt; time) {\t// ttl \u0026lt; time(等待时间)，代表在等待之间锁就已经释放了 // 信号量形式订阅等待锁释放 commandExecutor.getNow(subscribeFuture).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS); } else {\t// ttl \u0026gt; time(等待时间)，如果等了time的时间，经过time的时间，锁还没有被释放，也就没必要等了 commandExecutor.getNow(subscribeFuture).getLatch().tryAcquire(time, TimeUnit.MILLISECONDS); } // 剩余时间小于等于零，不再重试，返回失败 time -= System.currentTimeMillis() - currentTime; if (time \u0026lt;= 0) { acquireFailed(waitTime, unit, threadId); return false; } // 若time \u0026gt; 0，表示时间还很充足，仍可等待，继续执行while(true)循环 } } finally { // 取消订阅 unsubscribe(commandExecutor.getNow(subscribeFuture), threadId); } // return get(tryLockAsync(waitTime, leaseTime, unit)); } 这段代码实现了在等待获取锁的过程中对剩余时间的动态调整，确保在等待过程中可以根据实际情况调整等待时间，这里设计的巧妙之处就在于利用了PubSub消息订阅、信号量的机制，它不是无休止的这种盲等机制，也避免了不断的重试，而是检测到**锁被释放才去尝试重新获取，**这对CPU十分的友好。同时，使用 try-finally 语句块确保在获取锁过程中发生异常时可以正确地取消订阅并释放资源。\n![](Java/Java Web/Redis/files/a7347eeb920dd1d92a767f64061ca6cf_MD5.png)\n订阅的通知就是释放锁Lua脚本当中发布的通知，然后等待订阅结果，等待的时间就是time(锁的最大剩余时间);\n总结：只要给定waitTime最大重试时间，就可以做到锁重试的机制。\n看门狗（WatchDog）机制 线程获取锁，由于业务阻塞导致锁超时释放了该如何解决呢？这就需要使用看门狗机制了，确保我们的业务是执行完释放，而不是阻塞释放。\n源码分析： tryAcquireAsync()方法\n![](Java/Java Web/Redis/files/e9a6c7d96f9bb96a6a6947dad1b462da_MD5.png)\n当我们没有设置leaseTime的时候，也就是内部leaseTime=-1的时候，过期时间为默认 internalLockLeaseTime。查看如下代码可知 internalLockLeaseTime 调用 getLockwatchdogTimeout()赋值默认时间是30s。\n![](Java/Java Web/Redis/files/38313f0f5403da4743cdddae9c256f73_MD5.png)\n回到tryAcquireAsync()方法，接着看下面的续期方法\n![](Java/Java Web/Redis/files/aea901b4a506408f34e92d61edbf2e2c_MD5.png)\n进入 scheduleExpectationRenew(long threadId) 方法中查看\n![](Java/Java Web/Redis/files/3ad8532b9e36851d7fe3174e7ba15259_MD5.png)\nEXPIRATION_RENEWAL_MAP中的key我们进去看一下，发现这是一个concurrentHashMap，并且entryName由id和name两部分组成，id就是当前连接的id，name就是当前锁的名称。\n![](Java/Java Web/Redis/files/27341655e268a9b532dadcfe1bebc8ff_MD5.png)\nrenewExpiration()方法主要开启一段定时任务，不断的去更新有效期，定时任务的的时间就是internalLockLeaseTime / 3，默认也就是10s后刷新有效期\n// 到期续约方法 private void renewExpiration() { ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName()); if (ee == null) {\t// 从map中没有获取到，直接返回 return; } // Timeout定时任务，或者叫周期任务 Timeout task = getServiceManager().newTimeout(new TimerTask() {\t// 开启异步定时任务 @Override public void run(Timeout timeout) throws Exception { ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName()); if (ent == null) { return; } Long threadId = ent.getFirstThreadId(); if (threadId == null) { return; } // renewExpirationAsync()执行续命操作的方法，10s之后刷新有效期 CompletionStage\u0026lt;Boolean\u0026gt; future = renewExpirationAsync(threadId); future.whenComplete((res, e) -\u0026gt; { if (e != null) { if (getServiceManager().isShuttingDown(e)) { return; } log.error(\u0026#34;Can\u0026#39;t update lock {} expiration\u0026#34;, getRawName(), e); EXPIRATION_RENEWAL_MAP.remove(getEntryName()); return; } if (res) { // reschedule itself renewExpiration();\t// 递归调用自己，一直续期，直到锁释放 } else { cancelExpirationRenewal(null, null); } }); } // 刷新周期internalLockLeaseTime / 3， 默认释放时间是30秒，除以3就是每10秒更新一次 }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS); ee.setTimeout(task); } 因此，WatchDog机制其实就是一个后台定时任务线程，获取锁成功之后，会将持有锁的线程放入到一个RedissonBaseLock.EXPIRATION_RENEWAL_MAP里面，然后每隔10秒（internalLockLeaseTime / 3）检查一下，如果客户端1还持有锁key（判断客户端是否还持有key，其实就是遍历EXPIRATION_RENEWAL_MAP里面线程id，然后根据线程id去Redis中查，如果存在就会延长key的时间），那么就会不断的延长锁key的生存时间，重置锁的超时时间。\nrenewExpirationAsync方法源码，其调用了Lua脚本执行续期操作的。代码如下图：\n![](Java/Java Web/Redis/files/b8f0f30c3ea2287384424843bb4edfaa_MD5.png)\n看完了刷新续约操作，我们知道了它会递归调用续约方法，一直刷新有效期，那么什么时候才不进行续命呢？当然是在释放锁的时候。我们在来看看释放锁操作。\n释放锁操作，取消续期\n![](Java/Java Web/Redis/files/99fbddcb89028d705f2b085f41192cce_MD5.png)\n在unlockAsync0方法中，当执行完lua脚本删除锁操作后，返回了个future，当这个future执行完成后，就执行取消续期操作。源码如下图：\n![](Java/Java Web/Redis/files/bc27470a970f0ba6f3828e6645e7917e_MD5.png)\n跟进着看cancelExpirationRenewal(threadId)这个方法：\n![](Java/Java Web/Redis/files/380ffe69206c53ad560db2adc8847309_MD5.png)\n先从map中取出任务，先移除任务的线程Id，再取消这个任务，最后再移除entry，到这里看门狗的流程就结束了。\n注意：如果服务宕机了，因为没有人再去调用renewExpiration这个方法，WatchDog机制线程也就没有了，所以等到时间之后自然就释放了。此时就不会延长key的过期时间，到了30s之后就会自动过期了，其他线程就可以获取到锁。如果调用带过期时间的lock方法，则不会启动看门狗任务去自动续期。\n整体执行流程图：\n![](Java/Java Web/Redis/files/97df0b477fb73b94a01caba9bf0b014f_MD5.png)\n总结：看门狗机制是解决超时续期的问题，在获取锁成功以后，开启一个WatchDog定时任务，每隔一段时间（默认30 秒）就会去重置锁的超时时间，以确保锁是在程序执行完unlock手动释放的，不会发生因为业务阻塞key超时，而导致锁自动释放的情况。只要任务在运行中，看门狗就会持续续期。\n可重入：利用hash结构记录线程id和重入次数。\n可重试：利用信号量和PubSub功能实现等待、唤醒，获取锁失败的重试机制。\n超时续约：利用watchDog，每隔一段时间（releaseTime / 3），重置超时时间。\n3）Redisson的主从一致性问题解决方案（MultiLock原理）\n下面我们来看一下Redisson是如何解决主从一致性问题的。\n为了提高redis的可用性，我们会搭建集群或者主从，现在以主从为例\n![](Java/Java Web/Redis/files/f442275179a5343c1c8133ddc6b1ce00_MD5.png)\n此时我们去写命令，写在主机上， 主机会将数据同步给从机，但是假设在主机还没有来得及把数据写入到从机去的时候，此时主机宕机，哨兵会发现主机宕机，并且选举一个slave变成master，而此时新的master中实际上并没有锁信息，此时锁信息就已经丢掉了。\n![](Java/Java Web/Redis/files/e4a39187898d6897e6e9d27d2cfe2e74_MD5.png)\n为了解决这个问题，redission提出来了MutiLock锁，使用这把锁咱们就不使用主从了，每个节点的地位都是一样的， 这把锁加锁的逻辑需要写入到每一个主从节点上，只有所有的服务器都写入成功，此时才是加锁成功，假设现在某个节点挂了，那么他去获得锁的时候，只要有一个节点拿不到，都不能算是加锁成功，就保证了加锁的可靠性。\n![](Java/Java Web/Redis/files/785ee29bd01998ccd6e33abbf16a33f7_MD5.png)\n那么MultiLock加锁原理是什么呢？下面这幅图来说明\n![](Java/Java Web/Redis/files/25c3f53b23968e511ba1065b8c17b8d3_MD5.png)\n当我们去设置了多个锁时，redission会将多个锁添加到一个集合中，然后用while循环去不停去尝试拿锁，但是会有一个总共的加锁时间，这个时间是用需要加锁的个数 * 1500ms ，假设有3个锁，那么时间就是4500ms，假设在这4500ms内，所有的锁都加锁成功， 那么此时才算是加锁成功，如果在4500ms有线程加锁失败，则会再次去进行重试。\n测试主从节点锁的一致性 1）注入三个RedissonClient\n@Bean public RedissonClient redissonClient() { // 获取Redisson配置对象 Config config = new Config(); // 添加redis地址，这里添加的是单节点地址，也可以通过config.userClusterServers()添加集群地址 config.useSingleServer().setAddress(\u0026#34;redis://192.168.8.100:6379\u0026#34;).setPassword(\u0026#34;123321\u0026#34;); // 获取RedisClient对象，并交给IOC进行管理 return Redisson.create(config); } @Bean public RedissonClient2 redissonClient() { // 获取Redisson配置对象 Config config = new Config(); // 添加redis地址，这里添加的是单节点地址，也可以通过config.userClusterServers()添加集群地址 config.useSingleServer().setAddress(\u0026#34;redis://192.168.8.100:6380\u0026#34;); // 获取RedisClient对象，并交给IOC进行管理 return Redisson.create(config); } @Bean public RedissonClient3 redissonClient() { // 获取Redisson配置对象 Config config = new Config(); // 添加redis地址，这里添加的是单节点地址，也可以通过config.userClusterServers()添加集群地址 config.useSingleServer().setAddress(\u0026#34;redis://192.168.8.100:6381\u0026#34;); // 获取RedisClient对象，并交给IOC进行管理 return Redisson.create(config); } 2）编写测试类\n@Resource private RedissonClient redissonClient; @Resource private RedissonClient redissonClient2; @Resource private RedissonClient redissonClient3; private RLock lock; @BeforeEach void setUp(){ RLock lock1 = redissonClient.getLock(\u0026#34;lock\u0026#34;); RLock lock2 = redissonClient2.getLock(\u0026#34;lock\u0026#34;); RLock lock3 = redissonClient3.getLock(\u0026#34;lock\u0026#34;); // 创建联锁 MultiLock redissonClient.getMultiLock(lock1, lock2, lock3); } // 获取锁和释放锁代码与之前都是一样的，执行代码后可以在节点1、节点2、节点3中查看到锁，联锁中的每一个锁都是可重入锁 源码分析： 1）先来看获取锁：RLock multiLock = redisson.getMultiLock(lock1, lock2, lock3);实际上就是拿个一个数组来存放这些锁。\n@Override public RLock getMultiLock(RLock... locks) { return new RedissonMultiLock(locks); } // RedissonMultiLock.java // 存放到一个列表中 final List\u0026lt;RLock\u0026gt; locks = new ArrayList\u0026lt;\u0026gt;(); public RedissonMultiLock(RLock... locks) { if (locks.length == 0) { throw new IllegalArgumentException(\u0026#34;Lock objects are not defined\u0026#34;); } this.locks.addAll(Arrays.asList(locks)); } 2）加锁\n// RedissonMultiLock.java @Override public void lock() { try { lockInterruptibly(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } @Override public void lockInterruptibly() throws InterruptedException { lockInterruptibly(-1, null); } @Override public void lockInterruptibly(long leaseTime, TimeUnit unit) throws InterruptedException { // 1、计算等待时间 leaseTime=-1 unit=null // baseWaitTime等待时间 = 锁的数量（3个） * 1500 = 4500毫秒 long baseWaitTime = locks.size() * 1500; // 2、等待全部加锁成功 while (true) { long waitTime; if (leaseTime \u0026lt;= 0) { // waitTime = 4500毫秒 waitTime = baseWaitTime; } else {\t// 自定义等待时间 waitTime = unit.toMillis(leaseTime); if (waitTime \u0026lt;= baseWaitTime) { waitTime = ThreadLocalRandom.current().nextLong(waitTime/2, waitTime); } else { waitTime = ThreadLocalRandom.current().nextLong(baseWaitTime, waitTime); } } if (leaseTime \u0026gt; 0) { leaseTime = unit.toMillis(leaseTime); } // 不停的去获取锁，waitTime = 4500毫秒，leaseTime = -1 if (tryLock(waitTime, leaseTime, TimeUnit.MILLISECONDS)) { return; } } } 此过程，主要分为两部分：\n计算等待时间：跟锁数量相关; 3个锁，等待时间 = 3 * 1500 = 4500 尝试加全部锁：无限循环，至全部锁加成功 3）进入tryLock()，查看其源码：\n@Override public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException { // try { // return tryLockAsync(waitTime, leaseTime, unit).get(); // } catch (ExecutionException e) { // throw new IllegalStateException(e); // } long newLeaseTime = -1; if (leaseTime \u0026gt; 0) {\t// 给锁一个初始化的有效期，并且这个时间是大于我们需要的锁的有效期的 if (waitTime \u0026gt; 0) { newLeaseTime = unit.toMillis(waitTime)*2; } else { newLeaseTime = unit.toMillis(leaseTime); } } long time = System.currentTimeMillis();\t// 记录加锁的过程开始时间 long remainTime = -1;\t// 加锁剩余时间 if (waitTime \u0026gt; 0) { remainTime = unit.toMillis(waitTime); } long lockWaitTime = calcLockWaitTime(remainTime);\t// 官方弃用了RedissonRedLock, 就是剩余时间 int failedLocksLimit = failedLocksLimit();\t// 官方弃用了RedissonRedLock，failedLocksLimit()返回值是0 List\u0026lt;RLock\u0026gt; acquiredLocks = new ArrayList\u0026lt;\u0026gt;(locks.size());\t// 需要加锁的lock集合 for (ListIterator\u0026lt;RLock\u0026gt; iterator = locks.listIterator(); iterator.hasNext();) {\t// 拿到锁的迭代器 RLock lock = iterator.next();\t// 获取其中一个锁 boolean lockAcquired;\t// 是否加锁成功 try { if (waitTime \u0026lt;= 0 \u0026amp;\u0026amp; leaseTime \u0026lt;= 0) {\t// 如果等待时间，有效时间都没有设置就使用默认的方式去获取锁 lockAcquired = lock.tryLock(); } else { // awaitTime=-1，在tryLock中，-1代表了如果获取锁成功了，就会启动一个lock watchDog，不停的刷新锁的生存时间 long awaitTime = Math.min(lockWaitTime, remainTime); // 获取锁，等待awaitTime=4500毫秒，获取锁成功，启动一个watchDog lockAcquired = lock.tryLock(awaitTime, newLeaseTime, TimeUnit.MILLISECONDS); } } catch (RedisResponseTimeoutException e) {\t// 任意一台redis服务器响应超时了，就应该释放所有锁 unlockInner(Arrays.asList(lock)); lockAcquired = false; } catch (Exception e) { lockAcquired = false; } if (lockAcquired) {\t// 加锁成功了就添加进去 acquiredLocks.add(lock); } else { if (locks.size() - acquiredLocks.size() == failedLocksLimit()) {\t// 若是成功的锁达到了设定的值就不用再去获取锁了 break; } // 失败机制，设置为0 且还加锁失败了那就直接清空所有锁，本次获取锁失败了 if (failedLocksLimit == 0) { unlockInner(acquiredLocks); if (waitTime \u0026lt;= 0) { return false; } failedLocksLimit = failedLocksLimit(); acquiredLocks.clear(); // reset iterator while (iterator.hasPrevious()) { iterator.previous(); } } else { failedLocksLimit--; } } // 计算本次加锁花费的时间 ，看看是否超时 if (remainTime \u0026gt; 0) { // 如果获取锁成功，当前时间减去获取锁耗费的时间time remainTime -= System.currentTimeMillis() - time; time = System.currentTimeMillis(); if (remainTime \u0026lt;= 0) { // 如果remainTime \u0026lt; 0 说明获取锁超时，那么就释放掉这个锁 unlockInner(acquiredLocks); return false;\t// 返回false，加锁失败 } } } // 若是没超时说明都加锁成功了，就添加锁的过期时间 if (leaseTime \u0026gt; 0) { acquiredLocks.stream() .map(l -\u0026gt; (RedissonBaseLock) l) .map(l -\u0026gt; l.expireAsync(unit.toMillis(leaseTime), TimeUnit.MILLISECONDS)) .forEach(f -\u0026gt; f.toCompletableFuture().join()); } return true; } 4）unlock()释放锁，每个锁调用自己的unlock 方法\n// 在RedissonMultiLock中释放锁，就是依次调用所有的锁的释放的逻辑，lua脚本，同步等待所有的锁释放完毕，才会返回 @Override public void unlock() { locks.forEach(Lock::unlock); } 调用的是之前分析过的RedissonBaseLock中unlock方法，最终指向的是各自重写的unlockInnerAsync(),即异步解锁方法。\nRedisson分布式锁原理：\n可重入：利用hash结构记录线程id和重入次数。\n可重试：利用信号量和PubSub功能实现等待、唤醒，获取锁失败的重试机制。\n超时续约：利用watchDog，每隔一段时间（releaseTime / 3），重置超时时间。\n主从一致性：利用multiLock，多个独立的Redis节点，必须在所有节点都获取重入锁，才算获取锁成功。\n1）不可重入Redis分布式锁：\n原理：利用setnx的互斥性；利用ex避免死锁；释放锁时判断线程标示 缺陷：不可重入、无法重试、锁超时失效 2）可重入的Redis分布式锁：\n原理：利用hash结构，记录线程标示和重入次数；利用watchDog延续锁时间；利用信号量控制锁重试等待 缺陷：redis宕机引起锁失效问题 3）Redisson的multiLock：\n原理：多个独立的Redis节点，必须在所有节点都获取重入锁，才算获取锁成功 缺陷：运维成本高、实现复杂 12、Redis异步秒杀优化\r（1）秒杀业务性能测试\r在测试类中添加如下方法，根据1000条用户信息生成1000条token @SpringBootTest public class GenerateTokenTest { @Resource private IUserService userService; @Resource private StringRedisTemplate stringRedisTemplate; @Test public void generateToken() throws IOException { // 数据库查询1000个用户信息 List\u0026lt;User\u0026gt; userList = userService.list(new QueryWrapper\u0026lt;User\u0026gt;().last(\u0026#34;limit 1000\u0026#34;)); // 创建字符输出流准备写入token到文件 BufferedWriter br = new BufferedWriter(new FileWriter(\u0026#34;D:\\\\Develop\\\\Redis-code\\\\hm-dianping\\\\src\\\\test\\\\java\\\\com\\\\hmdp\\\\tokens.txt\u0026#34;)); for (User user : userList) { // 随机生成Token作为登录令牌 String token = UUID.randomUUID().toString(true); // 将User对象转为Hash存储 UserVo userVo = BeanUtil.copyProperties(user, UserVo.class); // 将User对象转为HashMap存储 Map\u0026lt;String, Object\u0026gt; userMap = BeanUtil.beanToMap(userVo, new HashMap\u0026lt;\u0026gt;(), CopyOptions.create() .setIgnoreNullValue(true) .setFieldValueEditor((fieldName, fieldValue) -\u0026gt; fieldValue.toString())); // 保存用户token到redis，设置token有效期 String tokenKey = LOGIN_USER_KEY_PREFIX + token; stringRedisTemplate.opsForHash().putAll(tokenKey, userMap); stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES); // 写入token到文件 br.write(token); br.newLine(); br.flush(); } } } 执行结果如下，生成了1000条用户登录token到tokens.txt，方便JMeter读取\n![](Java/Java Web/Redis/files/d2711817b52a409b586b63a173d2cad7_MD5.png) 清空订单表，更改秒杀库存为200\n![](Java/Java Web/Redis/files/de064a768001340c83ed15cabead1775_MD5.png)\nJmeter中线程数设为1000，执行时间为1秒，模拟1000个用户高并发访问秒杀业务\n![](Java/Java Web/Redis/files/8fe96166f41f1c9978b6dd6bb7e84187_MD5.png)\n在CSV数据文件设置中进行如下设置\n![](Java/Java Web/Redis/files/b924282226c67ad3232ab7c58d15d0b7_MD5.png)\n在HTTP信息头管理器中进行如下设置\n![](Java/Java Web/Redis/files/62f066aa1acf472805716641c1ab4908_MD5.png)\n测试秒杀id为14的优惠券接口\n![](Java/Java Web/Redis/files/6c66ef27ea3c1adc5fad5ac51d19a723_MD5.png)\n测试结果 由于JMeter模拟发送请求不是同时发送，是又少到多的请求，所以响应时间最小值是4毫秒，最大值是1727毫秒，平均响应时间是1215毫秒，吞吐量为556.2/sec（随着并发量增加，吞吐量减少）。\n![](Java/Java Web/Redis/files/996950dbddb160bb96f6e101f522e077_MD5.png)\n数据库200条数据正常扣减库存 ![](Java/Java Web/Redis/files/91e69c58cf5ce61285a69c8e2feb5faf_MD5.png)\n（2）异步秒杀思路分析\r之前的秒杀业务流程：\n![](Java/Java Web/Redis/files/12e5043d1979d6f3dd72377cf152442b_MD5.png)\n查询优惠券，判断是否在秒杀时间区间、判断秒杀券库存是否充足 查询订单（为了校验是否是一人一单） 扣减优惠券的库存（不能超卖，判断库存是否充足） 将用户抢购的优惠券信息写入订单，完成订单的创建，返回订单id 其中，查询优惠券、查询订单、扣减库存、创建订单四步都是数据库操作，前两步是对用户秒杀资格的判断，后两部是下单的数据库写操作耗时较久。\n我们的异步优化思路是：判断秒杀库存和校验一人一单的逻辑放到Redis中完成，在Redis中预先扣减库存（优化1：将数据库查询操作改为Redis查询，将秒杀资格的判断分离，缩短业务流程）保存优惠券id、用户id、订单id到阻塞队列，将耗时较久的减库存、创建订单这两步操作去开启独立的线程异步写入数据库（优化2：异步读取队列中的订单信息，完成下单）。\n==类似小票, 一个给用户, 一个后端自己执行保证成功.== ![](Java/Java Web/Redis/files/7e23413bd8141b666e482ad00e267c0d_MD5.png)\n那如何在Redis判断库存是否充足和一人一单呢？ 我们需要把优惠券库存信息和订单被哪些用户购买过的信息缓存在Redis中，我们应该选择什么样数据结构来保存这两个信息呢？\n对于优惠券库存信息，使用String类型，key为优惠券库存key前缀+优惠券id，value为库存。 对于订单购买信息，使用Set类型（方便去重判断一人一单），key为优惠券订单key前缀+优惠券id，value为购买过该优惠券的用户id集合 ![](Java/Java Web/Redis/files/e533aeb4afcb0f523d5df951c9b18b11_MD5.png)\n异步秒杀业务流程如下 ![](Java/Java Web/Redis/files/ef2d50e57655623673b83edff49ac83a_MD5.png)\n使用Lua脚本保证以下操作的原子性：判断用户秒杀资格，根据不同情况返回不同的标识（0：满足条件已下单、1：库存不足、2：该优惠券此用户已下过单），如果满足秒杀条件，预先扣减Redis中的库存（数据库的库存先不扣减，后面异步扣减），将用户id存入当前优惠券的Set集合中，作为下次判断一人一单的依据。 在Tomcat中，首先执行Lua脚本，判断返回的结果是否为0，如果没有购买资格返回提示信息，如果有购买资格，将优惠卷id。用户id和订单id存入阻塞队列，方便异步线程去读取信息，完成异步下单，最后返回订单id，至此基于Redis的秒杀业务已经结束，用户已经可以拿到订单id去完成支付操作了。 开启异步线程去读取阻塞队列中的信息，完成数据库下单和扣减库存的动作，这一步对时效性要求就不是那么高了。 （3）改进秒杀业务，提高并发性能\r需求：\n新增秒杀优惠券的同时，将优惠券库存信息保存到Redis中 基于Lua脚本，判断秒杀库存、一人一单，决定用户是否抢购成功 如果抢购成功，将优惠券id和用户id封装后存入阻塞队列 开启线程任务，不断从阻塞队列中获取信息，实现异步下单功能 新增秒杀优惠券的同时，将优惠券库存信息保存到Redis中。 /** * 优惠券Service的实现类 */ @Service public class VoucherServiceImpl extends ServiceImpl\u0026lt;VoucherMapper, Voucher\u0026gt; implements IVoucherService { @Resource private ISeckillVoucherService seckillVoucherService; @Resource private StringRedisTemplate stringRedisTemplate; @Override @Transactional public void addSecKillVoucher(Voucher voucher) { // 保存优惠券 save(voucher); // 保存秒杀信息 SeckillVoucher seckillVoucher = new SeckillVoucher(); seckillVoucher.setVoucherId(voucher.getId()); seckillVoucher.setStock(voucher.getStock()); seckillVoucher.setBeginTime(voucher.getBeginTime()); seckillVoucher.setEndTime(voucher.getEndTime()); seckillVoucherService.save(seckillVoucher); // 新增时将秒杀券库存保存到Redis中，不设置过期时间，秒杀到期后需要手动删除缓存 stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY_PREFIX + voucher.getId(), voucher.getStock().toString()); } } 测试新增秒杀券，返回的秒杀券id为16\n![](Java/Java Web/Redis/files/087f5afdc434ac92485e965a18aa0250_MD5.png)\n新增的秒杀券成功添加到数据库和Redis中\n![](Java/Java Web/Redis/files/22a44ba1cfb9e3be707ce5096c133aeb_MD5.png)\n![](Java/Java Web/Redis/files/213dafc794978a6eecb03c6d8f334703_MD5.png)\n基于Lua脚本，判断秒杀库存、一人一单，决定用户是否抢购成功。 -- 参数列表 local voucherId = ARGV[1] -- 优惠券id（用于判断库存是否充足） local userId = ARGV[2] -- 用户id（用于判断用户是否下过单） -- 构造缓存数据key local stockKey = \u0026#39;hmdp:seckill:stock:\u0026#39; .. voucherId -- 库存key local orderKey = \u0026#39;hmdp:seckill:order:\u0026#39; .. voucherId -- 订单key -- 脚本业务 -- 判断库存是否充足 if tonumber(redis.call(\u0026#39;get\u0026#39;, stockKey)) \u0026lt;= 0 then -- 库存不足，返回1 return 1 end -- 判断用户是否下过单 SISMEMBER orderKey userId，SISMEMBER：判断Set集合中是否存在某个元素，存在返回1，不存在放回0 if redis.call(\u0026#39;sismember\u0026#39;, orderKey, userId) == 1 then -- 存在，说明用户已经下单，返回2 return 2 end -- 缓存中预先扣减库存 incrby stockKey -1 redis.call(\u0026#39;incrby\u0026#39;, stockKey, -1) -- 下单（保存用户） sadd orderKey userId redis.call(\u0026#39;sadd\u0026#39;, orderKey, userId) -- 有下单资格，允许下单，返回0 return 0 在Java代码中调用seckill.lua脚本\n如果抢购成功，将优惠券id和用户id封装后存入阻塞队列，开启线程任务，不断从阻塞队列中获取信息，实现异步下单功能。 IVoucherOrderService /** * 优惠券订单Service */ public interface IVoucherOrderService extends IService\u0026lt;VoucherOrder\u0026gt; { /** * 秒杀下单优惠券 * @param voucherId 优惠券id * @return 下单id */ Result seckillVoucher(Long voucherId); /** * 判断是否是一人一单，如果是再去创建秒杀券订单 * @param userId 用户id * @param voucherId 订单id * @return 订单id */ Result createSecKillVoucherOrder(Long userId, Long voucherId); /** * 将创建的秒杀券订单异步写入数据库 * @param voucherOrder 订单信息 */ void createSecKillVoucherOrder(VoucherOrder voucherOrder); } 改造VoucherOrderServiceImpl /** * 优惠券订单Service实现类 */ @Service @Slf4j public class VoucherOrderServiceImpl extends ServiceImpl\u0026lt;VoucherOrderMapper, VoucherOrder\u0026gt; implements IVoucherOrderService { @Resource private ISeckillVoucherService seckillVoucherService; @Resource private RedisIDWorker redisIDWorker; @Resource private StringRedisTemplate stringRedisTemplate; @Resource private RedissonClient redissonClient; // RedisScript接口实现类 private static final DefaultRedisScript\u0026lt;Long\u0026gt; SECKILL_SCRIPT; // 静态代码块初始化加载脚本 static { SECKILL_SCRIPT = new DefaultRedisScript\u0026lt;\u0026gt;(); SECKILL_SCRIPT.setLocation(new ClassPathResource(\u0026#34;script/seckill.lua\u0026#34;)); SECKILL_SCRIPT.setResultType(Long.class); } // 阻塞队列特点：当一个线程尝试从队列中获取元素，没有元素，线程就会被阻塞，直到队列中有元素，线程才会被唤醒，并去获取元素 private BlockingQueue\u0026lt;VoucherOrder\u0026gt; orderTasks = new ArrayBlockingQueue\u0026lt;\u0026gt;(1024 * 1024); // 线程池 private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor(); @PostConstruct // 在类初始化时执行该方法 private void init() { // 启动线程池，执行任务 SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler()); } // 线程任务内部类 private class VoucherOrderHandler implements Runnable { // 线程任务: 不断从阻塞队列中获取订单信息 @Override public void run() { while (true) { try { // take()方法：从阻塞队列中获取元素，如果队列为空，线程会被阻塞，直到队列中有元素，线程才会被唤醒，并去获取元素 VoucherOrder voucherOrder = orderTasks.take(); // 创建订单 handleVoucherOrder(voucherOrder); } catch (Exception e) { log.error(\u0026#34;处理订单异常\u0026#34;, e); } } } } private void handleVoucherOrder(VoucherOrder voucherOrder) { // 从订单信息里获取用户id（从线程池中取出的是一个全新线程，不是主线程，所以不能从BaseContext中获取用户信息） Long userId = voucherOrder.getUserId(); // 创建锁对象(可重入)，指定锁的名称 RLock lock = redissonClient.getLock(LOCK_KEY_PREFIX + SECKILL_VOUCHER_ORDER + userId); // 尝试获取锁，空参默认失败不等待，失败直接返回 boolean isLock = lock.tryLock(); // 获取锁失败，返回错误或重试（这里理论上不需要再做加锁和判断，因为抢单环节的lua脚本已经保证了业务执行的原子性，不允许重复下单） if (!isLock) { log.error(\u0026#34;不允许重复下单，一个人只允许下一单！\u0026#34;); return; } try { // 将创建的秒杀券订单异步写入数据库 proxy.createSecKillVoucherOrder(voucherOrder); } finally { // 释放锁 lock.unlock(); } } // 事务代理对象 private IVoucherOrderService proxy; /** * 秒杀下单优惠券（Redis分布式锁+异步秒杀优化） * @param voucherId 优惠券id * @return 下单id */ @Override public Result seckillVoucher(Long voucherId) { // 获取用户id Long userId = ((UserVo) BaseContext.get()).getId(); // 执行Lua脚本 Long result = stringRedisTemplate.execute( SECKILL_SCRIPT, Collections.emptyList(), voucherId.toString(), userId.toString() ); // 判断结果是否为0 int r = result.intValue(); if (r != 0) { // 不为0，表示没有购买资格 return Result.fail(r == 1 ? \u0026#34;库存不足\u0026#34; : \u0026#34;不能重复下单\u0026#34;); } // 为0，表示有购买资格，把下单信息保存到阻塞队列 long orderId = redisIDWorker.nextId(SECKILL_VOUCHER_ORDER); // 创建订单（包括订单id，用户id，秒杀券id） VoucherOrder voucherOrder = new VoucherOrder(); voucherOrder.setId(orderId); // 订单id voucherOrder.setUserId(userId); // 用户id voucherOrder.setVoucherId(voucherId); // 秒杀券id // 获取当前目标对象的事务代理对象 proxy = (IVoucherOrderService) AopContext.currentProxy(); // 把订单信息保存到阻塞队列 orderTasks.add(voucherOrder); // 返回订单id return Result.ok(orderId); } /** * 将创建的秒杀券订单异步写入数据库 * @param voucherOrder 订单信息 */ @Transactional public void createSecKillVoucherOrder(VoucherOrder voucherOrder) { // 根据用户id和优惠券id查询订单是否存在 int count = query().eq(\u0026#34;user_id\u0026#34;, voucherOrder.getUserId()).eq(\u0026#34;voucher_id\u0026#34;, voucherOrder.getVoucherId()).count(); // 一人一单判断 if (count \u0026gt; 0) { // 该用户已经购买过了，不允许下多单 log.error(\u0026#34;该秒杀券用户已经购买过一次了！\u0026#34;); return; } // 扣减库存 boolean success = seckillVoucherService.update() .setSql(\u0026#34;stock = stock - 1\u0026#34;) // set stoke = stoke - 1 // where id = ? and stock \u0026gt; 0 .eq(\u0026#34;voucher_id\u0026#34;, voucherOrder.getVoucherId()) .gt(\u0026#34;stock\u0026#34;, 0) //.eq(\u0026#34;stock\u0026#34;, seckillVoucher.getStock()) // CAS乐观锁（成功卖出概率太低、需要用 stock \u0026gt; 0 来判断） .update(); if (!success) { // 扣减失败 log.error(\u0026#34;扣减失败，秒杀券扣减失败（库存不足）！\u0026#34;); return; } // 将订单信息写入数据库 success = this.save(voucherOrder); if (!success) { // 创建秒杀券订单失败 throw new RuntimeException(\u0026#34;创建秒杀券订单失败！\u0026#34;); } } } 注意：AopContext.currentProxy()底层也是利用ThreadLocal获取的，所以异步线程中也无法使用。解决方案就是提升代理对象的作用域，放到成员变量位置，在主线程中初始化，或者在主线程中创建后作为方法参数一起传递给阻塞队列。\n接口测试\n![](Java/Java Web/Redis/files/b872fe04aecb7d7786560409eaf837ca_MD5.png)\n先用ApiFox测试一人一单功能\n![](Java/Java Web/Redis/files/434fe93097e42908c5fa541d070a8bde_MD5.png)\n![](Java/Java Web/Redis/files/19234a4424f940756f59a4b3058f4d05_MD5.png)\n![](Java/Java Web/Redis/files/b600bddb52a4fc4d66c2d3f164afd27f_MD5.png)\n![](Java/Java Web/Redis/files/d6009cba2248f796b165815d89da187a_MD5.png)\n再来用JMeter做性能测试，结果发现，由于异步线程写入数据库，耗时减少，吞吐量大幅增加，提高了秒杀系统的并发性能！\n![](Java/Java Web/Redis/files/d8f4087f4523a2a8000219e936162c70_MD5.png)\n![](Java/Java Web/Redis/files/e463345ac1168d3f877501897072c74e_MD5.png)\n![](Java/Java Web/Redis/files/7e9d9ca0f97d74022580f05bcf78e162_MD5.png)\n总结\n秒杀业务的优化思路是什么？ 先利用Redis完成库存余量、一人一单判断，完成抢单业务 再将下单业务放入阻塞队列，利用独立线程异步下单 基于阻塞队列的异步秒杀存在哪些问题？ 内存限制问题（JDK的阻塞队列使用的是JVM的内存，高并发订单量可能导致内存溢出，队列大小是由我们自己指定的，可能会超出阻塞队列的上限） 数据安全问题（情况①：JVM内存是没有持久化机制的，服务重启或意外宕机时，阻塞队列中的所有任务都会丢失。情况②：当我们从阻塞队列拿到一个任务尚未处理时，如果此时发生异常，该任务也会丢失，就没有机会再次被处理了，导致数据不一致） 13、Redis消息队列实现异步秒杀\r消息队列（Message Queue）：字面意思就是存放消息的队列。最简单的消息队列模型包括3个角色：\n消息队列：存储和管理消息，也被称为消息代理（Message Broker）\n生产者：发送消息到消息队列\n消费者：从消息队列获取消息并处理消息 ![](Java/Java Web/Redis/files/26135307ebbe565599d9b3121838dc4e_MD5.png)\n核心优点：解耦、异步、削峰。\n常见的消息队列：RabbitMQ、RocketMQ、Kafka、ActiveMQ等，我们也可以直接使用Redis提供的MQ方案，降低我们的部署和学习成本。\nRedis提供了三种不同的方式来实现消息队列：\nlist结构：基于List结构模拟消息队列 PubSub：基本的点对点消息模型 Stream：比较完善的消息队列模型 （1）基于List结构模拟消息队列\rRedis的list数据结构是一个双向链表，很容易模拟出队列效果。\n队列是入口和出口不在一边，因此我们可以利用：LPUSH 结合 RPOP、或者 RPUSH 结合 LPOP来实现。不过要注意的是，当队列中没有消息时RPOP或LPOP操作会返回null，并不像JVM的阻塞队列那样会阻塞并等待消息。因此这里应该使用BRPOP或者BLPOP来实现阻塞效果。\n![](Java/Java Web/Redis/files/c266a64f308c19795e82dd8dfec0fb34_MD5.png)\n生产消息：BRPUSH key value [value ...] 将一个或多个元素推入到指定列表的头部。如果列表不存在，BRPUSH命令会自动创建一个新的列表。 消费消息：BRPOP key [key ...] timeout 从指定的一个或多个列表中弹出最后一个元素。如果 list 列表为空，BRPOP命令会导致客户端阻塞，直到有数据可用或超过指定的超时时间。 使用案例 ![](Java/Java Web/Redis/files/7584b440ebc7ef3d7487753fe7ccbce7_MD5.png)\n![](Java/Java Web/Redis/files/8b2ad6ccb318f799e58e14fd544ceaf2_MD5.png)\n![](Java/Java Web/Redis/files/b0098a8a2c63b9598b69cf274543865f_MD5.png)\n基于List的消息队列有哪些优缺点？\n优点：\n利用Redis存储，不受限于JVM内存上限 基于Redis的持久化机制，数据安全性有保证 可以满足消息有序性 缺点：\n无法避免消息丢失 只支持单消费者 （2）基于PubSub的消息队列\rPubSub（发布订阅）是Redis2.0版本引入的点对点消息传递模型。顾名思义，消费者可以订阅一个或多个channel，生产者向对应channel发送消息后，所有订阅者都能收到相关消息。\n![](Java/Java Web/Redis/files/7225f1e0d7371841404a3b54e95efff7_MD5.png)\n生产消息 # 向指定频道发布一条消息 PUBLISH channel message 消费消息 # 订阅一个或多个频道 SUBSCRIBE channel [channel ...] # 取消订阅一个或多个频道 UNSUBSCRIBE channel [channel ...] # 订阅与pattern格式匹配的所有频道 PSUBSCRIBE pattern [pattern ...] # 取消订阅与pattern格式匹配的所有频道 PUNSUBSCRIBE pattern [pattern ...] 使用案例 ![](Java/Java Web/Redis/files/21b459c723b00f1c01c9f5e4874ad5eb_MD5.png)\n![](Java/Java Web/Redis/files/b563f5167e787b8ede24fbeaf30aa6ac_MD5.png)\n![](Java/Java Web/Redis/files/14495d5d0805320b50d4ba41f909d38d_MD5.png)\n基于PubSub的消息队列有哪些优缺点？\n优点：\n采用发布订阅模型，支持多生产、多消费 缺点：\n不支持数据持久化 无法避免消息丢失（发送到的channel无消费者订阅，消息直接丢失，数据安全无法保证） 消息堆积有上限，超出时数据丢失（发送的消息如果有消费者监听，消息会缓存在消费者（客户端）的缓存区，若消费者处理消息耗时较久，新接收到的消息就会堆积，超出缓存上限就会丢失，因此可靠性不高） （3）基于Stream的消息队列\rStream是Redis 5.0引入的一种新数据类型，可以实现一个功能非常完善的消息队列。\nStream命令参数：https://redis.io/docs/latest/commands/?group=stream\n① Stream的单消费模式\r生产消息 ![](Java/Java Web/Redis/files/dbff590467facccd106f8f0af8700d5f_MD5.png)\n# 向指定的Stream中添加一个消息 XADD key [NOMKSTREAM] [MAXLEN|MINID [=|~] threshold [LIMIT count]] *|ID field value [field value ...] # 最简用法：XADD 添加消息的队列名称 消息id 消息Entry XADD key *|ID field value [field value ...] # 例如：创建名为users的队列，并向其中发送一个消息，内容是：{name=jack,age=21}，并且使用Redis自动生成ID 127.0.0.1:6379\u0026gt; XADD users * name jack age 21 \u0026#34;1644805700523-0\u0026#34; 消费消息 ![](Java/Java Web/Redis/files/68e33ab6da2f41d60775c1a117367833_MD5.png)\n# XREAD COUNT 读取消息数量 BLOCK 阻塞时长 STREAMS 要读取的阻塞队列名称 ID 起始id XREAD [COUNT count] [BLOCK milliseconds] STREAMS key [key ...] ID [ID ...] # 例如：使用XREAD读取第一条消息 XREAD COUNT 1 STREAMS users 0 # XREAD阻塞方式，读取最新的消息（阻塞1秒钟后读取最新消息） XREAD COUNT 1 BLOCK 1000 STREAMS users $ 在业务开发中，我们可以循环的调用XREAD阻塞方式来查询最新消息，从而实现持续监听队列的效果，伪代码如下：\n![](Java/Java Web/Redis/files/b7e0652077a34f50c4230dd62f75134f_MD5.png)\n注意：当我们指定起始ID为$时，代表读取最后一条消息（读取最新的消息），ID为0时代表读最开始的一条消息（读取最旧的消息）。如果我们处理一条消息的过程中，又有超过1条以上的消息到达队列，则下次获取时也只能获取到最新的一条，会出现漏读消息的问题。\nSTREAM类型消息队列的单消费模式（XREAD命令）有哪些优缺点？\n优点：\n消息可回溯（重复读取） 一个消息可以被多个消费者读取 可以阻塞读取 缺点：\n有消息漏读的风险 ② Stream的消费者组模式\r消费者组（Consumer Group）：将多个消费者划分到一个组中，监听同一个队列。具备下列特点\n![](Java/Java Web/Redis/files/cf9c3bdc2c5e77db74365c58c66f5271_MD5.png)\n消息分流：队列中的消息会分流给组内的不同消费者，而不是重复消费，从而加快消息处理的速度。 消息标示：消费者组会维护一个标示，记录最后一个被处理的消息，哪怕消费者宕机重启，还会从标示之后读取消息。确保每一个消息都会被消费。 消息确认：消费者获取消息后，消息处于pending（待处理）状态，并存入一个pending-list。当处理完成后需要通过XACK来确认消息，标记消息为已处理，才会从pending-list移除。 ![](Java/Java Web/Redis/files/da0fd35dd9f6f839001558481e99183f_MD5.png)\n![](Java/Java Web/Redis/files/0f6fb04677318a452b1cc336fa95040d_MD5.png)\n消费者监听消息的基本思路，伪代码如下：\n![](Java/Java Web/Redis/files/265644dc3656b72974a2c29a7fe350b1_MD5.png)\nSTREAM类型消息队列的XREADGROUP命令特点\n消息可回溯\n可以多消费者争抢消息，加快消费速度\n可以阻塞读取\n没有消息漏读的风险\n有消息确认机制，保证消息至少被消费一次\n三种Redis消息队列实现对比\n![](Java/Java Web/Redis/files/bb9c52c4d8c0b039c0ee21849bc3f8d0_MD5.png)\n（4）Stream消息队列优化异步秒杀\r![](Java/Java Web/Redis/files/da7f51341bdf6969c5548c2af652162c_MD5.png)\n1）创建一个Stream类型的消息队列，名为stream.orders\n方法一：在redis-cli中直接使用命令创建 # 创建队列（消费者组模式）MKSTREAM：当创建消费者组时，若队列不存在，将自动创建队列和消费者组 XGROUP CREATE stream.orders g1 0 MKSTREAM 方法二：在Java代码中创建 // Stream消息队列相关属性 private static final String GROUP_NAME = \u0026#34;g1\u0026#34;; // 消费者组 groupName private static final String CONSUMER_NAME = \u0026#34;c1\u0026#34;; // 消费者名称 consumer，该项后期可以在yaml中配置多个消费者，并实现消费者组多消费模式 private static final String QUEUE_NAME = \u0026#34;stream.orders\u0026#34;; // 消息队列名称 key @PostConstruct // 在类初始化时执行该方法 private void init() { // 创建消息队列 if (Boolean.FALSE.equals(stringRedisTemplate.hasKey(QUEUE_NAME))) { stringRedisTemplate.opsForStream().createGroup(QUEUE_NAME, ReadOffset.from(\u0026#34;0\u0026#34;), GROUP_NAME); log.debug(\u0026#34;Stream队列创建成功\u0026#34;); } // 启动线程池，执行任务 SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler()); } 2）修改之前的秒杀下单Lua脚本，在认定有抢购资格后，直接向stream.orders中添加消息，内容包含voucherld、userld、orderld\n修改lua脚本，加入xadd发送消息命令，向名为queueName的Stream消息队列发送下单信息 -- 参数列表 local voucherId = ARGV[1] -- 优惠券id（用于判断库存是否充足） local userId = ARGV[2] -- 用户id（用于判断用户是否下过单） local orderId = ARGV[3] -- 订单id local queueName = ARGV[4] -- Stream消息队列名称 -- 构造缓存数据key local stockKey = \u0026#39;hmdp:seckill:stock:\u0026#39; .. voucherId -- 库存key local orderKey = \u0026#39;hmdp:seckill:order:\u0026#39; .. voucherId -- 订单key -- 脚本业务 -- 判断库存是否充足 if tonumber(redis.call(\u0026#39;get\u0026#39;, stockKey)) \u0026lt;= 0 then -- 库存不足，返回1 return 1 end -- 判断用户是否下过单 SISMEMBER orderKey userId，SISMEMBER：判断Set集合中是否存在某个元素，存在返回1，不存在放回0 if redis.call(\u0026#39;sismember\u0026#39;, orderKey, userId) == 1 then -- 存在，说明用户已经下单，返回2 return 2 end -- 缓存中预先扣减库存 incrby stockKey -1 redis.call(\u0026#39;incrby\u0026#39;, stockKey, -1) -- 下单（保存用户） sadd orderKey userId redis.call(\u0026#39;sadd\u0026#39;, orderKey, userId) -- 发送订单消息到队列中 XADD stream.orders * k1 v1 k2 v2 ... redis.call(\u0026#39;xadd\u0026#39;, queueName, \u0026#39;*\u0026#39;, \u0026#39;userId\u0026#39;, userId, \u0026#39;voucherId\u0026#39;, voucherId, \u0026#39;id\u0026#39;, orderId) -- 有下单资格，允许下单，返回0 return 0 在Java代码中修改lua脚本调用，将阻塞队列添加消息改为lua脚本操作stream发送消息 /** * 秒杀下单优惠券（Stream消息队列优化异步秒杀） * @param voucherId 优惠券id * @return 下单id */ @Override public Result seckillVoucher(Long voucherId) { // 获取用户id Long userId = ((UserVo) BaseContext.get()).getId(); // 获取订单id long orderId = redisIDWorker.nextId(SECKILL_VOUCHER_ORDER); // 执行Lua脚本 Long result = stringRedisTemplate.execute( SECKILL_SCRIPT, Collections.emptyList(), voucherId.toString(), userId.toString(), String.valueOf(orderId), QUEUE_NAME ); // 判断结果是否为0 int r = result.intValue(); if (r != 0) { // 不为0，表示没有购买资格 return Result.fail(r == 1 ? \u0026#34;库存不足\u0026#34; : \u0026#34;不能重复下单\u0026#34;); } // 为0，表示有购买资格，lua脚本中已经将订单相关消息发送到消息队列中，待消费者读取 proxy = (IVoucherOrderService) AopContext.currentProxy(); // 获取当前目标对象的事务代理对象 // 返回订单id return Result.ok(orderId); } 3）项目启动时，开启一个线程任务，尝试获取stream.orders中的消息，完成下单\n// 线程池 private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor(); // Stream消息队列相关属性 private static final String GROUP_NAME = \u0026#34;g1\u0026#34;; // 消费者组 groupName private static final String CONSUMER_NAME = \u0026#34;c1\u0026#34;; // 消费者名称 consumer，该项后期可以在yaml中配置多个消费者，并实现消费者组多消费模式 private static final String QUEUE_NAME = \u0026#34;stream.orders\u0026#34;; // 消息队列名称 key @PostConstruct // 在类初始化时执行该方法 private void init() { // 创建消息队列 if (Boolean.FALSE.equals(stringRedisTemplate.hasKey(QUEUE_NAME))) { stringRedisTemplate.opsForStream().createGroup(QUEUE_NAME, ReadOffset.from(\u0026#34;0\u0026#34;), GROUP_NAME); log.debug(\u0026#34;Stream队列创建成功\u0026#34;); } // 启动线程池，执行任务 SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler()); } // 线程任务内部类 private class VoucherOrderHandler implements Runnable { @Override public void run() { while (true) { // 不断获取消息队列中的订单信息 try { // 获取消息队列中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS stream.orders \u0026gt; List\u0026lt;MapRecord\u0026lt;String, Object, Object\u0026gt;\u0026gt; list = stringRedisTemplate.opsForStream().read( Consumer.from(GROUP_NAME, CONSUMER_NAME), StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)), StreamOffset.create(QUEUE_NAME, ReadOffset.lastConsumed()) ); // 判断消息获取是否成功 if (list == null || list.isEmpty()) { // 如果获取失败，说明没有消息，继续下一次读取 continue; } // 解析消息中的订单信息 MapRecord\u0026lt;消息id, 消息key，消息value\u0026gt; MapRecord\u0026lt;String, Object, Object\u0026gt; record = list.get(0); Map\u0026lt;Object, Object\u0026gt; values = record.getValue(); VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(values, new VoucherOrder(), true); // 如果获取成功，可以下单 handleVoucherOrder(voucherOrder); // ACK确认 SACK stream.orders g1 id [id1 id2 id3 ...] stringRedisTemplate.opsForStream().acknowledge(QUEUE_NAME, GROUP_NAME, record.getId()); } catch (Exception e) { log.error(\u0026#34;处理订单异常\u0026#34;, e); handlePendingList(); } } } // 处理pending-list中的异常订单信息 private void handlePendingList() { while (true) { // 不断获取消息队列中的订单信息 try { // 获取pending-list中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 STREAMS stream.orders 0 List\u0026lt;MapRecord\u0026lt;String, Object, Object\u0026gt;\u0026gt; list = stringRedisTemplate.opsForStream().read( Consumer.from(GROUP_NAME, CONSUMER_NAME), StreamReadOptions.empty().count(1), StreamOffset.create(QUEUE_NAME, ReadOffset.from(\u0026#34;0\u0026#34;)) ); // 判断异常消息获取是否成功 if (list == null || list.isEmpty()) { // 如果获取失败，说明pending-list中没有异常消息，结束循环 break; } // 解析消息中的订单信息 MapRecord\u0026lt;消息id, 消息key，消息value\u0026gt; MapRecord\u0026lt;String, Object, Object\u0026gt; record = list.get(0); Map\u0026lt;Object, Object\u0026gt; values = record.getValue(); VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(values, new VoucherOrder(), true); // 如果获取成功，可以下单 handleVoucherOrder(voucherOrder); // ACK确认 SACK stream.orders g1 id [id1 id2 id3 ...] stringRedisTemplate.opsForStream().acknowledge(QUEUE_NAME, GROUP_NAME, record.getId()); } catch (Exception e) { log.error(\u0026#34;处理订单异常\u0026#34;, e); // 防止处理频繁，下次循环休眠20毫秒 try { Thread.sleep(20); } catch (InterruptedException ex) { throw new RuntimeException(ex); } } } } } 测试下单 恢复数据库和Redis为201个库存，先测试单人下单，正常下单。\n![](Java/Java Web/Redis/files/df01f0d2e41d1955ef5090798df5e317_MD5.png)\n再进行压力性能测试，与之前的阻塞队列的吞吐量差不多，但是Stream更加灵活和可靠。\n![](Java/Java Web/Redis/files/b25029167d65cf64028c836eb24bd0e2_MD5.png)\n五、达人探店\r1、发布探店笔记\r探店笔记类似点评网站的评价，往往是图文结合。对应的表有两个：\ntb_blog：探店笔记表，包含笔记中的标题、文字、图片等 tb_blog_comments：其他用户对探店笔记的评价 ![](Java/Java Web/Redis/files/aec30cfa8ef53de4441b710bc48b9487_MD5.png)\n这里主要先关注第一张表tb_blog，本功能已经实现，请求流程如下：\n![](Java/Java Web/Redis/files/808abd17d982442d580b0028dd68d47e_MD5.png)\nUploadController的uploadImage方法负责接收前端上传的图片，并根据文件名fileName和图片上传路径SystemConstants.IMAGE_UPLOAD_DIR保存文件并上传，最后返回给前端一个生成好的、可访问的图片地址。\n![](Java/Java Web/Redis/files/12b8257a23c336b7c7f6c3ebdfb78de7_MD5.png)\n修改图片上传地址，这里上传到本地服务器，在SystemConstants类中将图片上传地址修改到前端服务器nginx下的html\\hmdp\\imgs目录下。\n![](Java/Java Web/Redis/files/af3ac8036f3f0632c87fbad3b81a08df_MD5.png)\n点击发布后请求BlogController的saveBlog方法，保存笔记\n![](Java/Java Web/Redis/files/0b9e4ae30fb64ca6d7574492855100cc_MD5.png)\n测试功能 登录后点击下方+号，跳转发布探店笔记页面\n![](Java/Java Web/Redis/files/8c2b00aa804e2b2bc8a0259b0dc6ceaf_MD5.png)\n用户填写对应的标题、内容、选择关联的商户，上传探店图片\n![](Java/Java Web/Redis/files/23d53cf5e19846d99c3c0608748bd526_MD5.png)\n填写完成后点击发布，会自动跳到个人主页的笔记展示页面，成功发布探店笔记\n![](Java/Java Web/Redis/files/afb91637617a0264ec8810cb6d40f107_MD5.png)\n首页下方会出现新发布的笔记，因为还没有点赞，所以排在最下方\n![](Java/Java Web/Redis/files/e8adc595db3dfc7996cc517f10ecd0ec_MD5.png)\n同时，数据库也新增成功\n![](Java/Java Web/Redis/files/5b4301f3615043771e4909539caa43c9_MD5.png)\n2、查看探店笔记\r![](Java/Java Web/Redis/files/9521c931fa30ed778074908823261078_MD5.png)\n代码实现 /** * 探店笔记Service实现类 */ @Service public class BlogServiceImpl extends ServiceImpl\u0026lt;BlogMapper, Blog\u0026gt; implements IBlogService { @Resource private IUserService userService; @Override public Result queryHotBlog(Integer current) { // 根据用户查询 Page\u0026lt;Blog\u0026gt; page = query() .orderByDesc(\u0026#34;liked\u0026#34;) .page(new Page\u0026lt;\u0026gt;(current, SystemConstants.MAX_PAGE_SIZE)); // 获取当前页数据 List\u0026lt;Blog\u0026gt; records = page.getRecords(); // 查询用户 records.forEach(this::queryBlogUser); return Result.ok(records); } @Override public Result queryBlogById(Long id) { // 查询blog Blog blog = getById(id); if (blog == null) { return Result.fail(\u0026#34;笔记不存在！\u0026#34;); } // 查询blog有关的用户 queryBlogUser(blog); return Result.ok(blog); } private void queryBlogUser(Blog blog) { Long userId = blog.getUserId(); User user = userService.getById(userId); blog.setName(user.getNickName()); blog.setIcon(user.getIcon()); } } 测试功能 首页点击用户发布的探店笔记，进入详情页面\n![](Java/Java Web/Redis/files/4c4064ad0908ca3f3c8d75c80811cb79_MD5.png)\n3、Set实现点赞功能\r点赞请求流程： ![](Java/Java Web/Redis/files/d4360e49e79bbfc40a52d9e7254d661e_MD5.png)\n原始代码存在的问题：一个用户可以无限点赞。这显然是不合理的，所以我们需要对点赞功能进行一个优化，实现一人只能点赞一次。\n@PutMapping(\u0026#34;/like/{id}\u0026#34;) public Result likeBlog(@PathVariable(\u0026#34;id\u0026#34;) Long id) { // 修改点赞数量 blogService.update() .setSql(\u0026#34;liked = liked + 1\u0026#34;).eq(\u0026#34;id\u0026#34;, id).update(); return Result.ok(); } 需求：\n同一个用户只能点赞一次，再次点击则取消点赞 如果当前用户已经点赞，则点赞按钮高亮显示（前端已实现，判断字段Blog类的isLike属性） 实现步骤：\n给Blog类中添加一个isLike字段，标示是否被当前用户点赞 修改点赞功能，利用Redis的set集合判断是否点赞过，未点赞过则点赞数+1，已点赞过则点赞数-1 修改根据id查询Blog的业务，判断当前登录用户是否点赞过，赋值给isLike字段 修改分页查询Blog业务，判断当前登录用户是否点赞过，赋值给isLike字段 代码实现：使用Redis的Set集合存储已点赞用户的id，保证点赞唯一性 /** * 探店笔记Service实现类 */ @Service public class BlogServiceImpl extends ServiceImpl\u0026lt;BlogMapper, Blog\u0026gt; implements IBlogService { @Resource private IUserService userService; @Resource private StringRedisTemplate stringRedisTemplate; @Override public Result queryHotBlog(Integer current) { // 根据用户查询 Page\u0026lt;Blog\u0026gt; page = query() .orderByDesc(\u0026#34;liked\u0026#34;) .page(new Page\u0026lt;\u0026gt;(current, SystemConstants.MAX_PAGE_SIZE)); // 获取当前页数据 List\u0026lt;Blog\u0026gt; records = page.getRecords(); // 设置每一个热点笔记的用户，以及是否被点赞 records.forEach(blog -\u0026gt; { this.queryBlogUser(blog); // 查询blog有关的用户 this.isBlogLiked(blog); // 查询blog是否被点赞 }); return Result.ok(records); } @Override public Result queryBlogById(Long id) { // 查询blog Blog blog = getById(id); if (blog == null) { return Result.fail(\u0026#34;笔记不存在！\u0026#34;); } // 查询blog有关的用户 queryBlogUser(blog); // 查询blog是否被点赞 isBlogLiked(blog); return Result.ok(blog); } /** * 查询blog是否被点赞 * @param blog */ private void isBlogLiked(Blog blog) { // 获取当前登录用户 Long userId = ((UserVo) BaseContext.get()).getId(); // 判断当前用户是否已经点赞 String key = BLOG_LIKED_KEY_PREFIX + blog.getId(); Boolean isLiked = stringRedisTemplate.opsForSet().isMember(key, userId.toString()); blog.setIsLike(BooleanUtil.isTrue(isLiked)); } /** * Set实现用户点赞笔记功能 * @param id 笔记id * @return */ @Override public Result likeBlog(Long id) { // 获取当前登录用户 Long userId = ((UserVo) BaseContext.get()).getId(); // 判断当前用户是否已经点赞 String key = BLOG_LIKED_KEY_PREFIX + id; Boolean isLiked = stringRedisTemplate.opsForSet().isMember(key, userId.toString()); if (BooleanUtil.isFalse(isLiked)) { // 如果未点赞，可以点赞 // 数据库点赞数 + 1 boolean isSuccess = update().setSql(\u0026#34;liked = liked + 1\u0026#34;).eq(\u0026#34;id\u0026#34;, id).update(); if (isSuccess) { // 保存用户到Redis的Set集合 stringRedisTemplate.opsForSet().add(key, userId.toString()); } }else { // 如果已经点赞，取消点赞 // 数据库点赞数 - 1 boolean isSuccess = update().setSql(\u0026#34;liked = liked - 1\u0026#34;).eq(\u0026#34;id\u0026#34;, id).update(); if (isSuccess) { // 把用户从Redis的Set集合移除 stringRedisTemplate.opsForSet().remove(key, userId.toString()); } } return Result.ok(); } /** * 查询发布笔记的用户信息 * @param blog */ private void queryBlogUser(Blog blog) { Long userId = blog.getUserId(); User user = userService.getById(userId); blog.setName(user.getNickName()); blog.setIcon(user.getIcon()); } } 测试功能 登录后，点击笔记右下角的点赞按钮，点赞数+1，已点赞高亮显示。\n![](Java/Java Web/Redis/files/5edd2815d1e8be959203dcac9865ab87_MD5.png)\nRedis的Set集合中记录了该笔记已点赞的用户id\n![](Java/Java Web/Redis/files/4dd9bd6940922adc8435aaf3fc5f6c4a_MD5.png)\n同一个用户再次点赞同一篇笔记后，点赞数-1，高亮取消，Redis的Set集合中移除该用户id，防止了用户重复点赞。\n![](Java/Java Web/Redis/files/da8d83e720d19f0c9ce4b72871807051_MD5.png)\n4、Sorted Set实现点赞排行榜功能\r需求：按照点赞时间先后排序，返回Top5的用户\n![](Java/Java Web/Redis/files/b593317c42d08c0ba88fe37a59612d29_MD5.png)\n三种数据结构选择的对比：\n![](Java/Java Web/Redis/files/0d3b2fe6abd8702a73a3a13701b80eea_MD5.png)\nSet是无序的，无法满足这个需求，虽然List使用RPUSH可以满足有序性，但是不唯一，查找效率也比较低，而SortedSet可以根据score值进行默认升序排序。\nZADD key value score 向ZSet中添加元素，并指定score排序值 ZSCORE key value 判断ZSet是否有value元素，如果存在，返回该value对应的score值，如果不存在，则返回nil ZRANGE key start end 根据score排序值，查询并返回序号从start到end区间的value值 代码实现：改用ZSet后的存储结构，点赞后，value存储用户id、score存储点赞时的时间戳，并且实现查询点赞前5名的用户列表 /** * 查询blog是否被点赞，设置isLike属性 * @param blog */ private void isBlogLiked(Blog blog) { // 获取当前登录用户 UserVo userVo = BaseContext.get(); if (userVo == null) { // 用户未登录，无需查询是否点赞 return; } Long userId = userVo.getId(); // 判断当前用户是否已经点赞 String key = BLOG_LIKED_KEY_PREFIX + blog.getId(); Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString()); blog.setIsLike(score != null); } /** * Sorted Set实现用户点赞笔记功能 * @param id 笔记id * @return */ @Override public Result likeBlog(Long id) { // 获取当前登录用户 Long userId = ((UserVo) BaseContext.get()).getId(); // 判断当前用户是否已经点赞 String key = BLOG_LIKED_KEY_PREFIX + id; Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString()); if (score == null) { // 如果时间戳不存在，说明未点赞，可以点赞 // 数据库点赞数 + 1 boolean isSuccess = update().setSql(\u0026#34;liked = liked + 1\u0026#34;).eq(\u0026#34;id\u0026#34;, id).update(); if (isSuccess) { // 保存用户到Redis的ZSet集合 zadd key value score stringRedisTemplate.opsForZSet().add(key, userId.toString(), System.currentTimeMillis()); } }else { // 如果已经点赞，取消点赞 // 数据库点赞数 - 1 boolean isSuccess = update().setSql(\u0026#34;liked = liked - 1\u0026#34;).eq(\u0026#34;id\u0026#34;, id).update(); if (isSuccess) { // 把用户从Redis的ZSet集合移除 stringRedisTemplate.opsForZSet().remove(key, userId.toString()); } } return Result.ok(); } /** * 查询ZSet中的top5的点赞用户 * @param id 笔记id * @return */ @Override public Result queryBlogLikes(Long id) { // 查询top5的点赞用户 zrange key 0 4 String key = BLOG_LIKED_KEY_PREFIX + id; Set\u0026lt;String\u0026gt; top5 = stringRedisTemplate.opsForZSet().range(key, 0, 4); // 判断top5是否为空 if (CollUtil.isEmpty(top5)) { return Result.ok(Collections.emptyList()); } // 解析出其中的用户id List\u0026lt;Long\u0026gt; userIds = top5.stream().map(Long::valueOf).collect(Collectors.toList()); String userIdsStr = StrUtil.join(\u0026#34;,\u0026#34;, userIds);\t// 每个userId已逗号分隔 // 根据用户id查询用户，并转为UserVo集合 WHERE id IN (6, 2, 1) ORDER BY FIELD(id, 6, 2, 1) FIELD函数用于根据指定的顺序对结果进行排序 List\u0026lt;UserVo\u0026gt; userVoList = userService.query() .in(\u0026#34;id\u0026#34;, userIds).last(\u0026#34;ORDER BY FIELD(id, \u0026#34; + userIdsStr + \u0026#34;)\u0026#34;).list() .stream() .map(user -\u0026gt; BeanUtil.copyProperties(user, UserVo.class)) .collect(Collectors.toList()); // 返回UserVo集合 return Result.ok(userVoList); } 注意：如果直接使用MyBatis-Plus的listByIds方法，底层默认使用in进行条件查询，而MySQL查出来的结果默认是按照主键id升序排序的，这样直接返回给前端点赞排行榜顺序是不对的，而Redis中查出来是正确顺序（默认按照时间戳升序，也就是后点赞的排在后面），因此我们可以使用MySQL中的一个FIELD函数，该函数用于对指定字段按自定义的顺序对结果进行排序。\n测试功能 展示点赞前5名，先点赞的用户排在前面，后点赞的用户排在后面，同时支持取消点赞不重复\n![](Java/Java Web/Redis/files/5cc1c84503e8dc6de7075be43dcb4daf_MD5.png)\nRedis中的点赞顺序是按点赞时间更新顺序的\n![](Java/Java Web/Redis/files/5ed989c9f4f5feba8ded66322de28a5b_MD5.png)\n六、好友关注\r1、关注和取关\r![](Java/Java Web/Redis/files/0372bad79487b1c52097fbafded1735e_MD5.png)\n需求：基于该表数据结构，实现两个接口，用户可以对其他用户进行关注和取消关注功能。\n关注和取关接口 判断是否关注的接口 关注是User之间的关系，是博主与粉丝的关系，多对多，数据库用中间表tb_follow表示：\n![](Java/Java Web/Redis/files/915b79b2a4e221e607a125ea2cf5c714_MD5.png)\nFollowController /** * 用户关注Controller */ @RestController @RequestMapping(\u0026#34;/follow\u0026#34;) public class FollowController { @Resource private IFollowService followService; @PutMapping(\u0026#34;/{id}/{isFollow}\u0026#34;) public Result follow(@PathVariable(\u0026#34;id\u0026#34;) Long followUserId, @PathVariable(\u0026#34;isFollow\u0026#34;) Boolean isFollow) { return followService.follow(followUserId, isFollow); } @GetMapping(\u0026#34;/or/not/{id}\u0026#34;) public Result isFollow(@PathVariable(\u0026#34;id\u0026#34;) Long followUserId) { return followService.isFollow(followUserId); } } FollowServiceImpl /** * 用户关注Service实现类 */ @Service public class FollowServiceImpl extends ServiceImpl\u0026lt;FollowMapper, Follow\u0026gt; implements IFollowService { /** * 根据isFollow判断是关注还是取关，如果关注则插入数据，否则删除数据 * @param followUserId 被关注的用户id * @param isFollow 关注true/取关false * @return */ @Override public Result follow(Long followUserId, Boolean isFollow) { // 获取当前登录用户id Long userId = ((UserVo) BaseContext.get()).getId(); // 判断是关注还是取关 if (isFollow) { // 关注，新增数据 Follow follow = new Follow(); follow.setUserId(userId); follow.setFollowUserId(followUserId); save(follow); }else { // 取关，删除数据 delete from tb_follow where user_id = ? and follow_user_id = ? remove(new LambdaQueryWrapper\u0026lt;Follow\u0026gt;() .eq(Follow::getUserId, userId) .eq(Follow::getFollowUserId, followUserId)); } return Result.ok(); } /** * 判断当前登录用户是否关注了指定用户 * @param followUserId 被关注的用户id * @return 是否关注，true关注，false未关注 */ @Override public Result isFollow(Long followUserId) { // 获取当前登录用户id Long userId = ((UserVo) BaseContext.get()).getId(); // 查询是否关注 select count(*) from tb_follow where user_id = ? and follow_user_id = ? Long count = lambdaQuery() .eq(Follow::getUserId, userId) .eq(Follow::getFollowUserId, followUserId) .count(); // 判断是否关注 return Result.ok(count \u0026gt; 0); } } 这里使用lambdaQuery进行反射时报错，解决方法：将MP的版本升级到3.5.3，并且将count()方法的返回值改为Long。\n测试功能 首先自己不能关注自己，没有关注按钮，这里关注其他用户，关注成功后又查询了一次是否关注接口，按钮变为取消关注\n![](Java/Java Web/Redis/files/e588b38763c7a88075bcfa6f8e9f3fe3_MD5.png)\n数据库tb_follow表中插入关注数据\n![](Java/Java Web/Redis/files/16c37b5155454fe579a1fbbb7458a2f5_MD5.png)\n用户点击取消关注，取关成功，数据库tb_follow表中会删除相关关注数据。\n![](Java/Java Web/Redis/files/182da12f0702c7af50fad923e49d8631_MD5.png)\n2、共同关注\r![](Java/Java Web/Redis/files/5f8011ae4651d9d5aed5803b96a3ee60_MD5.png)\n![](Java/Java Web/Redis/files/73ea38865819ba46980705c2bc58b2ab_MD5.png)\n需求：利用Redis的Set集合求交集，实现共同关注功能。在博主个人页面展示出当前用户与博主的共同好友。\n![](Java/Java Web/Redis/files/90e4ca827b328c10b18e70f98ecef055_MD5.png)\n代码实现：修改关注和取关接口代码，在更新数据库的同时，将用户关注的id记录到Redis的Set集合中；新增查询目标用户的共同关注接口，求两个用户的Set集合的交集，即为共同关注 @Resource private StringRedisTemplate stringRedisTemplate; @Resource private IUserService userService; /** * 根据isFollow判断是关注还是取关，如果关注则插入数据，否则删除数据 * @param followUserId 被关注的用户id * @param isFollow 关注true/取关false * @return */ @Override public Result follow(Long followUserId, Boolean isFollow) { // 获取当前登录用户id Long userId = ((UserVo) BaseContext.get()).getId(); // 判断是关注还是取关 if (isFollow) { // 关注，新增数据 Follow follow = new Follow(); follow.setUserId(userId); follow.setFollowUserId(followUserId); boolean isSuccess = save(follow); if (isSuccess) { // 把关注用户的id放入Redis的Set集合 sadd userId followUserId stringRedisTemplate.opsForSet().add(FOLLOW_KEY_PREFIX + userId, followUserId.toString()); } }else { // 取关，删除数据 delete from tb_follow where user_id = ? and follow_user_id = ? boolean isSuccess = remove(new LambdaQueryWrapper\u0026lt;Follow\u0026gt;() .eq(Follow::getUserId, userId) .eq(Follow::getFollowUserId, followUserId)); if (isSuccess) { // 把关注用户的id从Redis集合中移除 stringRedisTemplate.opsForSet().remove(FOLLOW_KEY_PREFIX + userId, followUserId.toString()); } } return Result.ok(); } /** * 查询目标用户的共同关注 * @param id * @return */ @Override public Result followCommons(Long id) { // 获取当前用户 Long userId = ((UserVo) BaseContext.get()).getId(); String key = FOLLOW_KEY_PREFIX + userId; // 求交集 String targetKey = FOLLOW_KEY_PREFIX + id; Set\u0026lt;String\u0026gt; intersection = stringRedisTemplate.opsForSet().intersect(key, targetKey); if (CollUtil.isEmpty(intersection)) { // 无交集 return Result.ok(Collections.emptyList()); } List\u0026lt;Long\u0026gt; ids = intersection.stream().map(Long::valueOf).collect(Collectors.toList()); List\u0026lt;UserVo\u0026gt; userVoList = userService.listByIds(ids) .stream() .map(user -\u0026gt; BeanUtil.copyProperties(user, UserVo.class)) .collect(Collectors.toList()); return Result.ok(userVoList); } 功能测试 首先让 可可今天不吃肉(2) 和 小鱼同学(1) 关注 娃娃菜(6)，数据库和Redis中同步存入娃娃菜的id\n![](Java/Java Web/Redis/files/34737685fe2c2dc8f55f64d0f600d4ff_MD5.png)\n![](Java/Java Web/Redis/files/00c15f115059cf24008a8ff72f56110e_MD5.png)\n接着登录可可今天不吃肉(2)，查看小鱼同学(1)的共同关注里有娃娃菜(6)\n![](Java/Java Web/Redis/files/6f60acebcb51310fb366bcc39cb02891_MD5.png)\n3、关注推送\r（1）Feed流实现方案\r当我们关注了用户后，这个用户发了动态，那么我们应该把这些数据推送给用户，这个需求，其实我们又把他叫做Feed流，关注推送也叫做Feed流，直译为投喂。为用户持续的提供“沉浸式”的体验，通过无限下拉刷新获取新的信息。\n对于传统的模式的内容解锁：我们是需要用户去通过搜索引擎或者是其他的方式去解锁想要看的内容\n![](Java/Java Web/Redis/files/17424e352380505d2448588b40d38269_MD5.png)\n对于新型的Feed流的的效果：不需要用户自己去检索信息，而是系统分析用户到底想要什么，然后直接把内容推送给用户，从而使用户能够更加的节约时间，不用主动去寻找。\n![](Java/Java Web/Redis/files/dc78c723c3a93db726c799339f12167a_MD5.png)\nFeed流的产品实现有两种常见模式：\nTimeline：不做内容筛选，简单的按照内容发布时间排序，常用于好友或关注。例如朋友圈\n优点：信息全面，不会有缺失。并且实现也相对简单 缺点：信息噪音较多，用户不一定感兴趣，内容获取效率低 智能排序：利用智能算法屏蔽掉违规的、用户不感兴趣的内容。推送用户感兴趣信息来吸引用户。例如抖音、快手\n优点：投喂用户感兴趣信息，用户粘度很高，容易沉迷 缺点：如果算法不精准，可能起到反作用 本例中的个人页面，是基于关注的好友来做Feed流，因此采用Timeline的模式。该模式的实现方案有三种： 我们本次针对好友的操作，采用的就是Timeline的方式，只需要拿到我们关注用户的信息，然后按照时间排序即可，因此采用Timeline的模式。该模式的实现方案有三种：\n拉模式 推模式 推拉结合 ![](Java/Java Web/Redis/files/71b391aa3877d65d69814ba5be6cbdf7_MD5.png)\n拉模式：也叫做读扩散。在拉模式中，终端用户或应用程序主动发送请求来获取最新的数据流。它是一种按需获取数据的方式，用户可以在需要时发出请求来获取新数据。在Feed流中，数据提供方将数据发布到实时数据源中，而终端用户或应用程序通过订阅或请求来获取新数据。 例如，当张三和李四和王五发了消息后，都会保存在自己的邮箱中，假设赵六要读取信息，那么他会从读取他自己的收件箱，此时系统会从他关注的人群中，把他关注人的信息全部都进行拉取，然后在进行排序\n![](Java/Java Web/Redis/files/4127c4f6ca0830ee5c814ce20c7d5392_MD5.png)\n优点：节约空间，因为赵六在读信息时，并没有重复读取，而且读取完之后可以把他的收件箱进行清除。\n缺点：延迟较高，当用户读取数据时才去关注的人里边去读取数据，假设用户关注了大量的用户，那么此时就会拉取海量的内容，对服务器压力巨大。\n推模式：也叫做写扩散。在推模式中，数据提供方主动将最新的数据推送给终端用户或应用程序。数据提供方会实时地将数据推送到终端用户或应用程序，而无需等待请求。 推模式是没有写邮箱的，当张三写了一个内容，此时会主动的把张三写的内容发送到他的粉丝收件箱中去，假设此时李四再来读取，就不用再去临时拉取了\n![](Java/Java Web/Redis/files/92007c8d91b0291d4249cf06cd7d101f_MD5.png)\n优点：时效快，不用临时拉取\n缺点：内存压力大，假设一个大V写信息，很多人关注他， 就会写很多份数据到粉丝那边去\n推拉结合模式：也叫做读写混合，兼具推和拉两种模式的优点。在推拉结合模式中，数据提供方会主动将最新的数据推送给终端用户或应用程序，同时也支持用户通过拉取的方式来获取数据。这样可以实现实时的数据更新，并且用户也具有按需获取数据的能力。 ![](Java/Java Web/Redis/files/29b91d717fb627d1aa6b73dc162fd9d2_MD5.png)\n推拉模式是一个折中的方案，站在发件人这一段，如果是个普通的人，那么我们采用写扩散的方式，直接把数据写入到他的粉丝中去，因为普通的人他的粉丝关注量比较小，所以这样做没有压力，如果是大V，那么他是直接将数据先写入到一份到发件箱里边去，然后再直接写一份到活跃粉丝收件箱里边去，现在站在收件人这端来看，如果是活跃粉丝，那么大V和普通的人发的都会直接写入到自己收件箱里边来，而如果是普通的粉丝，由于他们上线不是很频繁，所以等他们上线时，再从发件箱里边去拉信息。\n（2）推送到粉丝收件箱\r需求：\n修改新增探店笔记的业务，在保存blog到数据库的同时，推送到粉丝的收件箱 收件箱满足可以根据时间戳排序，必须用Redis的数据结构实现 查询收件箱数据时，可以实现分页查询 Feed流中的分页问题：Feed流中的数据会不断更新，可能随时发生变化，所以数据的角标也在变化，因此不能采用传统的分页模式。 ![](Java/Java Web/Redis/files/344e887a84d4a75f4c71d22fa82416dc_MD5.png)\n假设在t1时刻，我们去读取第一页，此时page=1，size=5，那么我们拿到的就是106 这几条记录，假设现在t2时候又新发布了一条记录11，此时t3时刻，我们来读取第二页，读取第二页传入的参数是page=2，size=5，那么此时读取到的第二页实际上是从6开始，查询62 ，那么我们就读取到了重复的数据6，所以feed流的分页，不能采用原始方案来做。\nFeed流的滚动分页：因为我们存的数据是有序的，可以用翻页游标 lastId 记录每次操作的最后一条，然后从这个位置开始去读取数据，这样查询不依赖于角标，因此不会受到数据角标变化带来的影响。 ![](Java/Java Web/Redis/files/609bfd7af6a759e189950f8b81e6040d_MD5.png)\n从t1时刻开始，拿第一页数据，拿到了10 ~ 6，然后记录下当前最后一次拿取的记录，lastId就是6，t2时刻发布了新的记录11放到最前面，但是不会影响我们之前记录的6，此时t3时刻来查询第二页，第二页起始位置从6的下一个5开始获取，就拿到了5 ~ 1的记录。\n之前分析过，虽然List和SortedSet都能支持排序，但List结构依赖角标查询，因此不支持滚动分页。而SortedSet会按照score值排序，数据排序完会有一个排名，如果按排名查询，那和角标查询没有区别，而SortedSet还支持按score值的范围进行查询，因此我们可以采用SortedSet来做，按时间戳从大到小降序排列后进行范围查询，查询时每次记录最小的时间戳，下次查询时找比这个时间戳次小的，从这里开始，从而实现滚动分页。\n代码实现：保存笔记，并推送给所有粉丝 /** * 保存笔记，并推送给所有粉丝 * @param blog * @return */ @Override public Result saveBlog(Blog blog) { // 获取登录用户 UserVo userVo = BaseContext.get(); blog.setUserId(userVo.getId()); // 保存探店笔记 boolean isSuccess = save(blog); if (!isSuccess) { return Result.fail(\u0026#34;新增笔记失败！\u0026#34;); } // 查询笔记作者（当前登录用户）的所有粉丝（注意：之前Redis中存的是某用户关注的人，不是某用户的粉丝）select * from tb_follow where follow_user_id = ? List\u0026lt;Follow\u0026gt; follows = followService.lambdaQuery().eq(Follow::getFollowUserId, userVo.getId()).list(); // 推送笔记id给所有粉丝 for (Follow follow : follows) { // 获取粉丝id Long fanId = follow.getUserId(); // 推送笔记id String key = FEED_KEY_PREFIX + fanId; stringRedisTemplate.opsForZSet().add(key, blog.getId().toString(), System.currentTimeMillis()); } // 返回id return Result.ok(blog.getId()); } （3）实现滚动分页查询\r需求：在个人主页的“关注”卡片中，查询并展示推送的Blog信息\n![](Java/Java Web/Redis/files/513c58ef1ab2cd1fd9e77fcca203688a_MD5.png)\nZRANGE 是按照角标（排名）从小到大排序：\n![](Java/Java Web/Redis/files/d8f0c426bc369c7f66c12e2f247cc532_MD5.png)\nZREVRANGE 是按照角标从大到小排序：\n![](Java/Java Web/Redis/files/597c0ccf826bf177c6501ac0ead7b38c_MD5.png)\nZREVRANGEBYSCORE 是按照分数从大到小排序：\n![](Java/Java Web/Redis/files/5bda7eabe98e51f3db9fc2604dca2f31_MD5.png)\n其中的参数：\nmax：分数的最大值 min：分数的最小值 offset：偏移量 count：查的数量 滚动查询：每一次都记住上一次查询分数的最小值，将最小值作为下一次的最大值\n![](Java/Java Web/Redis/files/8644d71f42dda8f4fa2d2950469acf4d_MD5.png)\n当分数一致时offset给固定的1会出现问题，这里的offset应该为上一次查询到与最小值min相同的元素个数，上次查询到的最小值min，也就是下次来查询的最大值max\n![](Java/Java Web/Redis/files/3162a4d3460b26eecd7cb9ab29c133e7_MD5.png)\n规律总结：分数最小值min（无需关注最小值是几，固定给score能取到最小值就行）和 查的数量count（前端人为决定）固定不变。最大值max为上一次查询的最小值（第一次为当前时间戳）、偏移量offset第一次为0，之后为在上一次的结果中，与最小值相同的元素的个数。\nScrollResult /** * 滚动分页查询结果对象 */ @Data public class ScrollResult { // 结果集合 private List\u0026lt;?\u0026gt; list; // 本次查询的最小score值，作为下一次请求的lastIId private Long minTime; // 偏移量 private Integer offset; } BlogServiceImpl /** * 推送关注笔记，滚动分页查询 * @param max * @param offset * @return */ @Override public Result queryBlogOfFollow(Long max, Integer offset) { // 获取当前登录用户 Long userId = ((UserVo) BaseContext.get()).getId(); String key = FEED_KEY_PREFIX + userId; // 查询收件箱（关注推送笔记列表）ZREVRANGEBYSCORE key max min LIMIT offset count Set\u0026lt;ZSetOperations.TypedTuple\u0026lt;String\u0026gt;\u0026gt; typedTuples = stringRedisTemplate.opsForZSet() .reverseRangeByScoreWithScores(key, 0, max, offset, 2); // 非空判断 if (CollUtil.isEmpty(typedTuples)) { return Result.ok(); } // 解析数据：blogId、minTime（时间戳）、offset List\u0026lt;Long\u0026gt; blogIds = new ArrayList\u0026lt;\u0026gt;(typedTuples.size()); long minTime = 0L; // 循环最后一次取出的是最小时间戳 int os = 1; // 默认初始偏移量为1。表示只有自己是相同的 for (ZSetOperations.TypedTuple\u0026lt;String\u0026gt; typedTuple : typedTuples) { // 获取blogId blogIds.add(Long.valueOf(typedTuple.getValue())); // 获取score（时间戳） long time = typedTuple.getScore().longValue(); if (time == minTime) { // 当前时间等于最小时间，偏移量+1 os++; }else { // 当前时间不等于最小时间，更新覆盖最小时间，重置偏移量为1 minTime = time; os = 1; } } // 根据id查询blog，注意保持blogIds的有序性，封装为blog集合 String blogIdsStr = StrUtil.join(\u0026#34;,\u0026#34;, blogIds); List\u0026lt;Blog\u0026gt; blogs = query().in(\u0026#34;id\u0026#34;, blogIds).last(\u0026#34;order by field(id, \u0026#34; + blogIdsStr + \u0026#34;)\u0026#34;).list(); for (Blog blog : blogs) { // 设置blog有关的用户 queryBlogUser(blog); // 设置blog是否被点赞 isBlogLiked(blog); } // 封装为滚动分页结果对象，返回给前端 ScrollResult scrollResult = new ScrollResult(); scrollResult.setList(blogs); scrollResult.setMinTime(minTime); scrollResult.setOffset(os); return Result.ok(scrollResult); } 测试功能 现在可可今天不吃肉(2) 和 小鱼同学(1) 都关注了 娃娃菜(6)，并且娃娃菜发布了3篇笔记\n![](Java/Java Web/Redis/files/a81ec0782f979370aa9e8ea1667f8f05_MD5.png)\n登录可可今天不吃肉(2) 或 小鱼同学(1) 查看关注推送效果\n![](Java/Java Web/Redis/files/70c0ff7f28e093cf26a69fe4f1a6ee9e_MD5.png)\n七、附近商铺\r1、GEO的基本用法\rGEO就是Geolocation的简写形式，代表地理坐标。Redis在3.2版本中加入了对GEO的支持，允许存储地理坐标信息，帮助我们根据经纬度来检索数据。常见的命令有： ![](Java/Java Web/Redis/files/061a49b72267916242cdea7f868effda_MD5.png)\nGEOADD：添加一个地理空间信息，包含：经度（longitude）、纬度（latitude）、值（member） GEODIST：计算指定的两个点之间的距离并返回 GEOHASH：将指定member的坐标转为hash字符串形式并返回 GEOPOS：返回指定member的坐标 GEORADIUS：指定圆心、半径，找到该圆内包含的所有member，并按照与圆心之间的距离排序后返回。6.2以后已废弃 GEOSEARCH：在指定范围内搜索member，并按照与指定点之间的距离排序后返回。范围可以是圆形或矩形。6.2.新功能，代替GEORADIUS GEOSEARCHSTORE：与GEOSEARCH功能一致，不过可以把结果存储到一个指定的key。 6.2.新功能 ![](Java/Java Web/Redis/files/ac8ee95b41e5476949b80c668711f3e9_MD5.png)\n# 添加坐标数据 GEOADD g1 116.378248 39.865275 bjnz 116.42803 39.903738 bjz 116.322287 39.893729 bjxz # 计算北京西站到北京站的距离 GEODIST g1 bjnz bjz km # 搜索天安门附近10km内的所有火车站，并按照距离升序排序（默认升序） GEOSEARCH g1 FROMLONLAT 116.397904 39.909005 BYRADIUS 10 km WITHDIST ![](Java/Java Web/Redis/files/4cd6fe897d077db5d0dc3f6848a2fb10_MD5.png)\n![](Java/Java Web/Redis/files/626a61102b1a13927dc1545bf9af26e6_MD5.png)\n2、附近商户搜索\r![](Java/Java Web/Redis/files/e3e768b8c4960d127a7b30e509f123e3_MD5.png)\n前端发请求携带的经纬度坐标是指：当前登录用户的坐标。这里仅演示写死的，但真实项目中经纬度坐标信息会对接第三方服务接口获取，实时定位不会写死的。\n数据库存储分析 ![](Java/Java Web/Redis/files/0989a080e67cf1199214c52a2068cd62_MD5.png)\n存储方案设计：按照商户类型做分组，类型相同的商户作为同一组，以typeld为key存入同一个GEO集合中即可（在店铺新增存入的时候，就提前用key分好组） ![](Java/Java Web/Redis/files/7abab0f1f1ecc37a2f0480200784cf77_MD5.png)\n将数据库中已经存在的店铺数据按typeId分组导入Redis @Test void loadShopData() { // 查询店铺信息 List\u0026lt;Shop\u0026gt; shops = shopService.list(); // 把店铺分组，按照typeId分组，id一致的放到一个集合 Map\u0026lt;Long, List\u0026lt;Shop\u0026gt;\u0026gt; groupByShopTypeToMap = shops.stream().collect(Collectors.groupingBy(Shop::getTypeId)); // 分批完成写入Redis for (Map.Entry\u0026lt;Long, List\u0026lt;Shop\u0026gt;\u0026gt; entry : groupByShopTypeToMap.entrySet()) { // 获取类型id Long typeId = entry.getKey(); String geoKey = SHOP_GEO_KEY_PREFIX + typeId; // 获取同类型的店铺的集合 List\u0026lt;Shop\u0026gt; shopList = entry.getValue(); List\u0026lt;RedisGeoCommands.GeoLocation\u0026lt;String\u0026gt;\u0026gt; locations = new ArrayList\u0026lt;\u0026gt;(shopList.size()); // 写入Redis的GEO GEOADD KEY 经度 纬度 Member for (Shop shop : shopList) { //stringRedisTemplate.opsForGeo().add(geoKey, new Point(shop.getX(), shop.getY()), shop.getId().toString()); locations.add(new RedisGeoCommands.GeoLocation\u0026lt;\u0026gt;( shop.getId().toString(), new Point(shop.getX(), shop.getY()) )); } // 批量写入Redis的GEO stringRedisTemplate.opsForGeo().add(geoKey, locations); } } ![](Java/Java Web/Redis/files/e0d65456c55592d63693d49ca3075a2f_MD5.png)\n为了使用 GEOSEARCH 命令，引入新版本lettuce和spring-data-redis依赖，排除旧版本依赖 ![](Java/Java Web/Redis/files/0699da4f4ab0dc08cf0d45c15e9b7d5b_MD5.png)\npom.xml \u0026lt;!-- spring-data-redis --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-data-redis\u0026lt;/artifactId\u0026gt; \u0026lt;exclusions\u0026gt; \u0026lt;exclusion\u0026gt; \u0026lt;artifactId\u0026gt;lettuce-core\u0026lt;/artifactId\u0026gt; \u0026lt;groupId\u0026gt;io.lettuce\u0026lt;/groupId\u0026gt; \u0026lt;/exclusion\u0026gt; \u0026lt;exclusion\u0026gt; \u0026lt;artifactId\u0026gt;spring-data-redis\u0026lt;/artifactId\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.data\u0026lt;/groupId\u0026gt; \u0026lt;/exclusion\u0026gt; \u0026lt;/exclusions\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!-- 引入新版本lettuce和spring-data-redis --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.data\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-data-redis\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.6.2\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;io.lettuce\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;lettuce-core\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;6.1.6.RELEASE\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; ShopServiceImpl：在普通查询数据库的基础上，增加实现查询附近商户功能 /** * 根据店铺类型分组查询店铺信息，支持查询附近商户，按距离升序排序 * @param typeId 店铺类型 * @param current 当前页码 * @param x 用户经度 * @param y 用户纬度 * @return 店铺数据 */ @Override public Result queryShopByType(Integer typeId, Integer current, Double x, Double y) { // 判断是否需要根据坐标查询 if (x == null || y == null) { // 不需要坐标查询，按数据库查询，根据类型分页查询 Page\u0026lt;Shop\u0026gt; page = query().eq(\u0026#34;type_id\u0026#34;, typeId).page(new Page\u0026lt;\u0026gt;(current, SystemConstants.DEFAULT_PAGE_SIZE)); // 返回数据 return Result.ok(page.getRecords()); } // 计算分页参数 int from = (current - 1) * SystemConstants.DEFAULT_PAGE_SIZE; int end = current * SystemConstants.DEFAULT_PAGE_SIZE; // 根据坐标查询redis，按照距离排序、分页查询。结果：shopId，maxDistance String geoKey = SHOP_GEO_KEY_PREFIX + typeId; // GEOSEARCH key BYLONLAT x y BYRADIUS 10 WITHDISTANCE WITHHASH GeoResults\u0026lt;RedisGeoCommands.GeoLocation\u0026lt;String\u0026gt;\u0026gt; results = stringRedisTemplate.opsForGeo().search( geoKey, GeoReference.fromCoordinate(x, y), // 查询以给定的经纬度为中心的圆形区域 new Distance(10000), // 查询10km范围内的店铺，单位默认为米 RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs().includeDistance().limit(end) // 分页查询0~end条 ); // 解析出id if (results == null) { // 未查到结果，返回错误 return Result.fail(\u0026#34;没有查到店铺\u0026#34;); } List\u0026lt;GeoResult\u0026lt;RedisGeoCommands.GeoLocation\u0026lt;String\u0026gt;\u0026gt;\u0026gt; list = results.getContent(); // from跳过前面元素不足from个，跳过后集合为空，说明查完了没有下一页了，返回空集合 if (list.size() \u0026lt;= from) { return Result.ok(Collections.emptyList()); } // 截取from ~ end的部分，方法一：list.subList(from, end); 方法二：stream流的skip方法，跳过from前面的元素，从from开始，截取end-from个元素 List\u0026lt;Long\u0026gt; ids = new ArrayList\u0026lt;\u0026gt;(list.size()); Map\u0026lt;String, Distance\u0026gt; distanceMap = new HashMap\u0026lt;\u0026gt;(list.size()); list.stream().skip(from).forEach(result -\u0026gt; { // 获取店铺id（Member） String shopIdStr = result.getContent().getName(); ids.add(Long.valueOf(shopIdStr)); // 获取距离 Distance distance = result.getDistance(); distanceMap.put(shopIdStr, distance); }); // 根据id查询店铺数据 String idStr = StrUtil.join(\u0026#34;,\u0026#34;, ids); List\u0026lt;Shop\u0026gt; shops = query().in(\u0026#34;id\u0026#34;, ids).last(\u0026#34;ORDER BY FIELD(id,\u0026#34; + idStr + \u0026#34;)\u0026#34;).list(); // 遍历店铺数据，设置距离 for (Shop shop : shops) { shop.setDistance(distanceMap.get(shop.getId().toString()).getValue()); } return Result.ok(shops); } 测试效果 ![](Java/Java Web/Redis/files/76051fab1538508dd368bf08d8e43cf9_MD5.png)\n八、用户签到\r1、BitMap的基本用法\r![](Java/Java Web/Redis/files/74e48d11bb124edaef0480553ebb0431_MD5.png)\nbitmap实际上就是由一个一个的二进制位所组成的，在bitmap中每一个位只存放0或者1，如下所示的bitmap结构图：\n![](Java/Java Web/Redis/files/b5e73ac05c6b653692204a6a056d17bd_MD5.png)\nRedis中是利用String类型数据结构实现存储BitMap，因此最大上限是512M，转换为bit则是232个bit位（1024_1024_8*512=42亿）。\nBitMap的操作命令有：\nSETBIT：向指定位置（offset）存入一个0或1 GETBIT：获取指定位置（offset）的bit值 BITCOUNT：统计BitMap中值为1的bit位的数量 BITFIELD：操作（查询、修改、自增）BitMap中bit数组中的指定位置（offset）的值 BITFIELD_RO：获取BitMap中bit数组，并以十进制形式返回 BITOP：将多个BitMap的结果做位运算（与 、或、异或） BITPOS：查找bit数组中指定范围内第一个0或1出现的位置 ![](Java/Java Web/Redis/files/8b4ea4b4b419c47d77303fe3e307d8ac_MD5.png)\n以8bit的一字节存储，如超出8个位，自动扩容到16位进行存储，后面未设置1的位置补0，以此类推。\nu表示无符号，i表示有符号，一般使用无符号u。例如BITFIELD bm1 get u2 0中u2表示获取两个bit位，0表示从0位置开始获取，我们存入的数据是11100111，从0开始计数，往后数两个bit位就是11，11的十进制是3，所以返回3。同理BITFIELD bm1 get u3 0对应的就是111，返回的数据就是7。\n注意：Redis客户端（RESP），必须使用2020以前版本，或者2022.2之后的版本，2021不支持二进制数据的展示。\n2、实现签到功能\r![](Java/Java Web/Redis/files/d07ee218d33b2d80e0c06a2153479301_MD5.png)\nRedis将BitMap的所有操作封装到字符串String中了，因此spring-data-redis使用opsForValue，操作BitMap。\n实现用户每日签到 /** * 用户每日签到 * @return */ @Override public Result sign() { // 获取当前登录用户 Long userId = ((UserVo) BaseContext.get()).getId(); // 获取当前日期 LocalDateTime now = LocalDateTime.now(); // 拼接key String keySuffix = now.format(DateTimeFormatter.ofPattern(\u0026#34;:yyyy:MM\u0026#34;)); String key = USER_SIGN_KEY_PREFIX + userId + keySuffix; // 获取今天是本月的第几天（offset = dayOfMonth - 1） int dayOfMonth = now.getDayOfMonth(); // 写入redis完成签到 SETBIT key offset 1 stringRedisTemplate.opsForValue().setBit(key, dayOfMonth - 1, true); return Result.ok(); } 由于签到没有做对应的前端界面，所以这里用ApiFox测试接口 ![](Java/Java Web/Redis/files/89ea7eeed29bd43ae4b558c6da1ccb33_MD5.png)\n3、实现统计连续签到天数\r![](Java/Java Web/Redis/files/ffc54967a66f3bf5e798c767793f7fe4_MD5.png)\n问题1：什么叫做连续签到天数? 从最后一次签到开始向前统计，直到遇到第一次未签到为止，计算总的签到次数，就是连续签到天数。\n问题2：如何得到本月到今天为止的所有签到数据？ BITFIELD key GET u[dayOfMonth] 0\n例1：BITFIELD key GET u1 0 // 从0开始，统计1天\n例2：BITFIELD key GET u2 0 // 从0开始，统计2天\n例3：BITFIELD key GET u2 0 // 从0开始，统计3天\n例4：BITFIELD key GET u[dayOfMonth] 0 // 从0开始，统计dayOfMonth天\n问题3：如何从后向前遍历每个bit位? 与1做与运算，就能得到最后一个bit位。随后右移1位，丢弃最后1位，下一个bit位就成为最后一个bit位，直到遍历到从右往左数第一个为0的位置，结束统计。\n统计截止今日的连续签到次数 /** * 统计截止今日的连续签到次数 * @return 返回连续签到天数 */ @Override public Result signCount() { // 获取当前登录用户 Long userId = ((UserVo) BaseContext.get()).getId(); // 获取当前日期 LocalDateTime now = LocalDateTime.now(); // 拼接key String keySuffix = now.format(DateTimeFormatter.ofPattern(\u0026#34;:yyyy:MM\u0026#34;)); String key = USER_SIGN_KEY_PREFIX + userId + keySuffix; // 获取今天是本月的第几天（offset） int dayOfMonth = now.getDayOfMonth(); // 获取本月截止今天的所有签到记录，返回一个十进制的数字 BITFIELD key GET u[dayOfMonth] 0 List\u0026lt;Long\u0026gt; results = stringRedisTemplate.opsForValue().bitField( key, BitFieldSubCommands.create().get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth)).valueAt(0) ); // 没有任何签到结果 if (CollUtil.isEmpty(results)) { return Result.ok(0); } long num = results.get(0).longValue(); if (num == 0) return Result.ok(0); // 循环遍历，计数统计 int count = 0; while ((num \u0026amp; 1) != 0) { // 如果最后一位不为0，说明已签到，计数器+1 count++; // 把数字右移一位，抛弃最后一位，继续下一个位 num \u0026gt;\u0026gt;\u0026gt;= 1; // 因为java中的整形都是有符号数，\u0026gt;\u0026gt;\u0026gt;是无符号右移，左边最高位补0，如果是\u0026gt;\u0026gt;最高位补符号位，对于正数来说，无符号右移和有符号右移结果相同 } return Result.ok(count); } 测试功能 ![](Java/Java Web/Redis/files/3afc01ff221b10ea9cfbb1ecc4b1bd43_MD5.png)\n![](Java/Java Web/Redis/files/a56858dbd6230a0b2921c6979327bfd4_MD5.png)\n![](Java/Java Web/Redis/files/e37da8782c8780510e9b728d604b7654_MD5.png)\n![](Java/Java Web/Redis/files/2223f6d4e5f91600139082f55df17ac4_MD5.png)\n九、UV统计\r1、HyperLogLog的基本用法\r首先我们搞懂两个概念：\nUV：全称Unique Visitor，也叫独立访客量，是指通过互联网访问、浏览这个网页的自然人。1天内同一个用户多次访问该网站，只记录1次。 PV：全称Page View，也叫页面访问量或点击量，用户每访问网站的一个页面，记录1次PV，用户多次打开页面，则记录多次PV。往往用来衡量网站的流量。 UV统计在服务端做会比较麻烦，因为要判断该用户是否已经统计过了，需要将统计过的用户信息保存。但是如果每个访问的用户都保存到Redis中，数据量会非常恐怖。\nHyperLogLog用法 Hyperloglog(HLL)是从Loglog算法派生的概率算法，用于确定非常大的集合的基数，而不需要存储其所有值。相关算法原理大家可以参考：https://juejin.cn/post/6844903785744056333#heading-0\nRedis中的HLL是基于string结构实现的，单个HLL的内存永远小于16kb，内存占用低的令人发指！作为代价，其测量结果是概率性的，有小于**0.81％**的误差。不过对于UV统计来说，这完全可以忽略。\n![](Java/Java Web/Redis/files/72b0e11b302bc762e68cacd10ecc9c46_MD5.png)\nHyperLogLog常用指令： PFADD key element [element...]：添加指定元素到HyperLogLog中 PFCOUNT key [key ...]：返回给定HyperLogLog的基数估算值，即统计key的value有多少个 PFMERGE destkey sourcekey [sourcekey ...]：将多个HyperLogLog合并为一个HyperLogLog HyperLogLog的作用：做海量数据的唯一性统计工作 ![](Java/Java Web/Redis/files/39c319702b0dd48ae37a49ce2f327335_MD5.png)\nHyperLogLog的优缺点： 优点：内存占用极低、性能非常好 缺点：有一定的误差 HLL与布隆过滤器的有些特点比较相似，目标都是节省存储空间，结果都是非精准，都有一定的设置误判率或误差率的能力。布隆过滤器核心在于判定是元素是否存在，HLL核心是计数。\n2、实现百万数据的统计\r由于当前系统并没有足够的用户数据量，所以这里我们需要模拟实现UV统计\n为了看出HLL占用内存大小，我们先查看一下当前Redis内存占用情况\n# 查看Redis内存 info memory ![](Java/Java Web/Redis/files/0349b3005e41249f705fa1b422c18ad5_MD5.png)\n当前Redis已占用内存大小为2152944 Byte，used_memory_human为2.05MB。\n存储模拟用户数据 @Test void testHyperLogLog() { String[] values = new String[1000]; for (int i = 0; i \u0026lt; 1000000; i++) { values[i % 1000] = \u0026#34;user_\u0026#34; + i; // 每1000次，添加一次，添加到Redis if (i % 1000 == 999) { stringRedisTemplate.opsForHyperLogLog().add(\u0026#34;hl2\u0026#34;, values); } } // 统计数量 Long count = stringRedisTemplate.opsForHyperLogLog().size(\u0026#34;hl2\u0026#34;); System.out.println(\u0026#34;count = \u0026#34; + count); // count = 997593 } 再使用info memory查看HLL存储100w条数据后内存：2167328 Byte，之前是2152944 Byte，相差(2167328 - 2152944) / 1024 = 14.046875KB，看看误差率 (1000000 - 997593) / 1000000 ≈ 0.002407%，远低于官方说的0.81%，再次运行插入相同数据去重后count仍为997593。\n总结：HyperLogLog统计数据量基本准确，并且还能过滤重复元素，存储海量数据占用内存极低，满足了大数据量的UV统计。\n参考文章：\n黑马点评项目学习笔记（15w字详解，堪称史上最详细，欢迎收藏） Kyle’s Blog Redis实战篇 黑马程序员 Redis 踩坑及解决 美团首次面试项目话术1——黑马点评篇 【BAT面试题系列】面试官：你了解乐观锁和悲观锁吗？ Java的CAS原理与应用 Lua 教程 | 菜鸟教程 并发编程系列之ReentrantLock用法简介 ","date":"2026-04-11T00:00:00Z","permalink":"/p/redis-%E5%AE%9E%E6%88%98%E9%A1%B9%E7%9B%AE-%E9%BB%91%E9%A9%AC%E7%82%B9%E8%AF%84%E6%9C%AC%E5%9C%B0%E6%95%B4%E7%90%86/","title":"Redis 实战项目 - 黑马点评本地整理"},{"content":"Redis 原理篇 - 数据结构与底层实现\r✨作者：猫十二懿\n❤️‍🔥账号：CSDN 、掘金 、个人博客 、Github、语雀\n🎉公众号：猫十二懿\nRedis(原理篇)\r一、数据结构\r1.1 动态字符串SDS(简单动态字符串)\r我们都知道Redis中保存的Key是字符串，value往往是字符串或者字符串的集合。可见字符串是Redis中最常用的一种数据结构。\n不过Redis没有直接使用C语言中的字符串，因为C语言字符串存在很多问题\n获取字符串的长度需要通过计算（因为c语言中其实没有字符串而是字符数组，通过最后的 ‘\\0’ 来表示结束，所以在计算数组长度的时候需要减去这个’\\0’） 非二进制安全(因为是通过使用 ‘\\0’ 来表示结束那么在这个字符串的中间都不能有特殊符号) 不可修改 ![image-20230411124527605](Java/Java Web/Redis/files/a7cca7c349a37c8ed406ef26f841928f_MD5.png)\nRedis构建了一种新的字符串结构，称为简单动态字符串（Simple Dynamic String），简称SDS。 例如，我们执行命令：\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653984583289.png](Java/Java Web/Redis/files/96fda915357878c7935542542778331a_MD5.png)\n那么Redis将在底层创建两个SDS，其中一个是包含“name”的SDS，另一个是包含“虎哥”的SDS。\nRedis是C语言实现的，其中SDS是一个结构体，源码如下：\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653984624671.png](Java/Java Web/Redis/files/ab28f859cefa81dcedeeb0c4bea9a688_MD5.png)\n例如，一个包含字符串“name”的sds结构如下：\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653984648404.png](Java/Java Web/Redis/files/7806c69b1e468be03844b8f7723eae9c_MD5.png)\nSDS之所以叫做动态字符串，是因为它具备动态扩容的能力，例如一个内容为“hi”的SDS：\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653984787383.png](Java/Java Web/Redis/files/5948c2f42809f0bcba630f828b614737_MD5.png)\n假如我们要给SDS追加一段字符串“,Amy”，这里首先会申请新内存空间：\n如果新字符串小于1M，则新空间为扩展后字符串长度的两倍+1；+1是最后的\u0026quot;\\0\u0026quot;\n如果新字符串大于1M，则新空间为扩展后字符串长度+1M+1。称为内存预分配。\n真正申请的是13个, 但是这里的alloc不包括最后的 \\0 ![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653984822363.png](Java/Java Web/Redis/files/73ffc217ba6ec84e0616a55251d7de6b_MD5.png)\n动态字符串优点：\n获取字符串长度的时间复杂度为0(1)[因为长度已经存在于结构体中] 支持动态扩容 减少内存分配次数 二进制安全（可以存储特殊字符，无需考虑结束符的问题） 1.2 Redis数据结构-IntSet\rIntSet是Redis中set集合的一种实现方式，基于整数数组来实现，并且具备长度可变、有序等特征。 结构如下：\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653984923322.png](Java/Java Web/Redis/files/7f9918f1e35dd87d06677ad2195d8b06_MD5.png)\n其中的encoding包含三种模式，表示存储的整数大小不同：\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653984942385.png](Java/Java Web/Redis/files/7c864db4ca2f4e953583e0b88848d756_MD5.png)\n为了方便查找，Redis会将intset中所有的整数按照升序依次保存在contents数组中，结构如图：\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653985149557.png](Java/Java Web/Redis/files/1432d90e6a1019a2fd0d1226db4740bb_MD5.png)\n寻址公式：startPtr【开始的起始地址为0】+（sizeof(int16) 【数据类型的字节大小】* index【它对应的下标】）就可以快速找到对应的数据\n现在，数组中每个数字都在int16_t的范围内，因此采用的编码方式是INTSET_ENC_INT16，每部分占用的字节大小为：\nencoding：4字节 length：4字节 contents：2字节 * 3 = 6字节 1.2.1 IntSet 升级\r现在，假设有一个intset,元素为{5,10,20}，采用的编码是INTSET_ENC_INT16，则每个整数占2字节：\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653985197214.png](Java/Java Web/Redis/files/1933419eabb400e198579ce73a92bd45_MD5.png)\n我们向该其中添加一个数字：50000，这个数字超出了int16_t的范围，intset会自动升级编码方式到合适的大小。 以当前案例来说流程如下：\n升级编码为INTSET_ENC_INT32, 每个整数占4字节，并按照新的编码方式及元素个数扩容数组 倒序依次将数组中的元素拷贝到扩容后的正确位置（倒叙放是为了防止，正序放的时候，字节扩大的时候会将后面的数据给覆盖掉） 将待添加的元素放入数组末尾 最后，将inset的encoding属性改为INTSET_ENC_INT32，将length属性改为4 ![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653985276621.png](Java/Java Web/Redis/files/7be18fc5dcccec42edbee2cd99b2dedb_MD5.png)\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653985304075.png](Java/Java Web/Redis/files/61608d1daf64f703bf784d7c82008715_MD5.png)\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653985327653.png](Java/Java Web/Redis/files/e07d7ef7ee72f29747705fde33516855_MD5.png)\n在length+prepend中如果我们之前判断的prepend如果是正数那么就是0即在倒序插入的时候原来元素的角标不会改变，只在扩容后的数组或者原数组最后加上新的数据就行，如果为负数那么prepend的值就是为1，那么所有元素就要向前移动一位\n小总结：\nIntset可以看做是特殊的整数数组，具备一些特点：\nRedis会确保Intset中的元素唯一、有序\n具备类型升级机制，可以节省内存空间\n底层采用二分查找方式来查询\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/image-20230415093332258.png](Java/Java Web/Redis/files/c57a83b7eaaada532f6b4a239b69f921_MD5.png)\n1.3 Redis数据结构-Dict\r我们知道Redis是一个键值型（Key-Value Pair）的数据库，我们可以根据键实现快速的增删改查。而键与值的映射关系正是通过Dict来实现的。 Dict由三部分组成，分别是：哈希表（DictHashTable）、哈希节点（DictEntry）、字典（Dict）\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653985396560.png](Java/Java Web/Redis/files/1309bff61e1f8d8a26b4752c729d56fc_MD5.png)\n当我们向Dict添加键值对时，Redis首先根据key计算出hash值（h），然后利用 h \u0026amp; sizemask来计算元素应该存储到数组中的哪个索引位置。我们存储k1=v1，假设k1的哈希值h =1，则1\u0026amp;3 =1，因此k1=v1要存储到数组角标1位置。\nsizemask = size-1, size是2^n, 所以sizemask的二进制低位都是1, 和hash值做与运算会得到hash值的低位, 相当于去余数 新元素使用的是头插法, 每次新元素是会添加到最开始 ![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653985497735.png](Java/Java Web/Redis/files/7c78f6758fac36b8426c867558adf776_MD5.png)\nDict由三部分组成，分别是：哈希表（DictHashTable）、哈希节点（DictEntry）、字典（Dict）\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653985570612.png](Java/Java Web/Redis/files/99020811ef48dcb7338e632b54e80d85_MD5.png)\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653985640422.png](Java/Java Web/Redis/files/78fe6ba99893e3cb0f24f9c3029b8387_MD5.png)\n1.3.1 Dict 扩容 / 收缩\rDict中的HashTable就是数组结合单向链表的实现，当集合中元素较多时，必然导致哈希冲突增多，链表过长，则查询效率会大大降低。 Dict在每次新增键值对时都会检查负载因子（LoadFactor = used/size） ，满足以下两种情况时会触发哈希表扩容： 哈希表的 LoadFactor \u0026gt;= 1，并且服务器没有执行 BGSAVE 或者 BGREWRITEAOF 等后台进程（因为这种操作对性能要求高，如果我在在进行rehash的操作就可能导致阻塞）； 哈希表的 LoadFactor \u0026gt; 5 ；\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653985716275.png](Java/Java Web/Redis/files/b26d503e4ce67fcc3df547318aaf80c3_MD5.png)\n扩展到大于等于used+1, 的第一个2^n dict_force_resize_ratio是5\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653985743412.png](Java/Java Web/Redis/files/ecfabbc65df6a21fe819212cd5d5c376_MD5.png)\nHASHTABLE_MIN_FILL是10\n负载因子小于0.1时触发收缩 Dict的rehash\n不管是扩容还是收缩，必定会创建新的哈希表，导致哈希表的size和sizemask变化，而key的查询与sizemask有关。因此必须对哈希表中的每一个key重新计算索引，插入新的哈希表，这个过程称为rehash。过程是这样的：\n计算新hash表的realeSize，值取决于当前要做的是扩容还是收缩：\n如果是扩容，则新size为第一个大于等于dict.ht[0].used + 1的2^n 如果是收缩，则新size为第一个大于等于dict.ht[0].used的2^n （不得小于4） 按照新的realeSize申请内存空间，创建dictht，并赋值给dict.ht[1]\n设置dict.rehashidx = 0，标示开始rehash, 正常没有rehash情况rehashidx是-1\n每次执行新增、查询、修改、删除操作时，都检查一下dict.rehashidx是否大于-1，如果是则将dict.ht[0].table[rehashidx]的entry链表rehash到dict.ht[1]，并且将rehashidx++。直至dict.ht[0]的所有数据都rehash到dict.ht[1]\n将dict.ht[1]赋值给dict.ht[0]，给dict.ht[1]初始化为空哈希表，释放原来的dict.ht[0]的内存\n将rehashidx赋值为-1，代表rehash结束\n在rehash过程中，新增操作，则直接写入ht[1]，查询、修改和删除则会在dict.ht[0]和dict.ht[1]依次查找并执行。这样可以确保ht[0]的数据只减不增，随着rehash最终为空\n整个过程可以描述成：\n当ht[0]大小只有4的时候这个时候，新添加了一个dictEntry，这个时候就ht[1]就会申请一个大小 \u0026gt;=userd+1大小的2^n的dictht1，将原来在ht[0]的数据渐进的转移到ht[1]中，最后ht[0]指针指向ht[1],将dict.ht[1]初始化为空哈希表，释放原来的dict.ht[0]的内存\n![image-20230412135147796](Java/Java Web/Redis/files/7f7c3dcce4e1e77ff94b77c4556e7ec7_MD5.png)\n![image-20230412135629733](Java/Java Web/Redis/files/cf72e12fa20d4b4fda24b00b38bc5367_MD5.png)\n问题: 主线程在增删的时候会进行检查, 可能会进行rehash, 当数据很多时reahash可能会导致主线程长时间卡死, 因此rehash是多次. 渐进式完成的. 渐进式: 每次增删改查时 将reahshidx 下标rehash到新hash表\n删改查时: 两个hash表都要找, 新增操作直接在ht1中插入\n1.4 Redis数据结构-ZipList\rZipList 是一种特殊的“双端链表” ，由一系列特殊编码的连续内存块组成。可以在任意一端进行压入/弹出操作, 并且该操作的时间复杂度为 O(1)。\n这里的尾偏移量的作用就是为了可以直接定位到最后一个entry节点，当我们知道了起始地址在加上尾偏移量就可以找到最后一个节点\n![image-20230412142739668](Java/Java Web/Redis/files/5e0f5e8d506aeba9b2493870aca63244_MD5.png)\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653986020491.png](Java/Java Web/Redis/files/7ce9645010c4ce805be7110533c396e5_MD5.png)\n属性 类型 长度 用途 zlbytes uint32_t 4 字节 记录整个压缩列表占用的内存字节数 zltail uint32_t 4 字节 记录压缩列表表尾节点距离压缩列表的起始地址有多少字节，通过这个偏移量，可以确定表尾节点的地址。 zllen uint16_t 2 字节 记录了压缩列表包含的节点数量。 最大值为UINT16_MAX （65534），如果超过这个值，此处会记录为65535，但节点的真实数量需要遍历整个压缩列表才能计算得出。 entry 列表节点 不定 压缩列表包含的各个节点，节点的长度由节点保存的内容决定。 zlend uint8_t 1 字节 特殊值 0xFF （十进制 255 ），用于标记压缩列表的末端。 ZipListEntry\nZipList 中的Entry并不像普通链表那样记录前后节点的指针，因为记录两个指针要占用16个字节，浪费内存。而是采用了下面的结构： previous_entry_length：前一节点的长度，占1个或5个字节。\n如果前一节点的长度小于254字节，则采用1个字节来保存这个长度值 如果前一节点的长度大于254字节，则采用5个字节来保存这个长度值，第一个字节为0xfe，后四个字节才是真实长度数据 encoding：编码属性，记录content的数据类型（字符串还是整数）以及长度，占用1个、2个或5个字节\ncontents：负责保存节点的数据，可以是字符串或整数\nZipList中所有存储长度的数值均采用小端字节序，即低位字节在前，高位字节在后。例如：数值0x1234，采用小端字节序后实际存储值为：0x3412\n高位高地址, 低位低地址, 12是高位, 地址大, 34是低位, 地址较小 Encoding编码\nZipListEntry中的encoding编码分为字符串和整数两种： 字符串：如果encoding是以“00”、“01”或者“10”开头，则证明content是字符串\n编码 编码长度 字符串大小 00pppppp 1 bytes \u0026lt;= 63 bytes 01pppppp qqqqqqqq 2 bytes \u0026lt;= 16383 bytes 10000000qqqqqqqqrrrrrrrrsssssssstttttttt 5 bytes \u0026lt;= 4294967295 bytes 例如，我们要保存字符串：“ab”和 “bc”\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653986172002.png](Java/Java Web/Redis/files/912b290e8a8d9885bb78ac3e79155584_MD5.png)\nZipListEntry中的encoding编码分为字符串和整数两种：\n整数：如果encoding是以“11”开始，则证明content是整数，且encoding固定只占用1个字节 编码 编码长度 整数类型 11000000 1 int16_t（2 bytes） 11010000 1 int32_t（4 bytes） 11100000 1 int64_t（8 bytes） 11110000 1 24位有符整数(3 bytes) 11111110 1 8位有符整数(1 bytes) 1111xxxx 1 直接在xxxx位置保存数值，范围从0001~1101，减1后结果为实际值 ![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653986282879.png](Java/Java Web/Redis/files/fbefe00ff819ee4a64c020598823177c_MD5.png)\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653986217182.png](Java/Java Web/Redis/files/8a060a6143b571a9d6c1198d893b3378_MD5.png)\n1.4.1 Redis数据结构-ZipList的连锁更新问题\rZipList的每个Entry都包含previous_entry_length来记录上一个节点的大小，长度是1个或5个字节： 如果前一节点的长度小于254字节，则采用1个字节来保存这个长度值 如果前一节点的长度大于等于254字节，则采用5个字节来保存这个长度值，第一个字节为0xfe，后四个字节才是真实长度数据 现在，假设我们有N个连续的、长度为250~253字节之间的entry，因此entry的previous_entry_length属性用1个字节即可表示，如图所示：\nZipList这种特殊情况下产生的连续多次空间扩展操作称之为连锁更新（Cascade Update）。新增、删除都可能导致连锁更新的发生。\n小总结：\nZipList特性：\n压缩列表的可以看做一种连续内存空间的\u0026quot;双向链表\u0026quot; 列表的节点之间不是通过指针连接，而是记录上一节点和本节点长度来寻址，内存占用较低 如果列表数据过多，导致链表过长，可能影响查询性能 增或删较大数据时有可能发生连续更新问题 1.5 Redis数据结构-QuickList\r问题1：ZipList虽然节省内存，但申请内存必须是连续空间，如果内存占用较多，申请内存效率很低。怎么办？\n答：为了缓解这个问题，我们必须限制ZipList的长度和entry大小。\n问题2：但是我们要存储大量数据，超出了ZipList最佳的上限该怎么办？\n答：我们可以创建多个ZipList来分片存储数据。\n问题3：数据拆分后比较分散，不方便管理和查找，这多个ZipList如何建立联系？\n答：Redis在3.2版本引入了新的数据结构QuickList，它是一个双端链表，只不过链表中的每个节点都是一个ZipList。\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653986474927.png](Java/Java Web/Redis/files/6b7e80d29f038e790b69651fd75473dc_MD5.png)\n为了避免QuickList中的每个ZipList中entry过多，Redis提供了一个配置项：list-max-ziplist-size来限制。\n如果值为正，则代表ZipList的允许的entry个数的最大值(但是这样的话，如果我每一个entry都很大的话，对内存还是有负担) 如果值为负，则代表ZipList的最大内存大小，分5种情况：\n-1：每个ZipList的内存占用不能超过4kb -2：每个ZipList的内存占用不能超过8kb -3：每个ZipList的内存占用不能超过16kb -4：每个ZipList的内存占用不能超过32kb -5：每个ZipList的内存占用不能超过64kb 其默认值为 -2：\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653986642777.png](Java/Java Web/Redis/files/7d98ba42583839d67fa1a11e43b431f2_MD5.png)\n以下是QuickList的和QuickListNode的结构源码：\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653986667228.png](Java/Java Web/Redis/files/5ea5edffe1e69fb8b284e334ab1be74e_MD5.png)\n我们接下来用一段流程图来描述当前的这个结构\n收尾两个节点没有被压缩, 中间节点被压缩了 ![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653986718554.png](Java/Java Web/Redis/files/03a14cb278b314695eb7a359ec47ad65_MD5.png)\n总结：\nQuickList的特点：\n是一个节点为ZipList的双端链表 节点采用了ZipList，解决了传统链表的内存占用问题 控制了ZipList大小，解决连续内存空间申请的效率问题 中间节点可以压缩，进一步节省内存 链表: 存储无上限, 占用内存过多, ziplist: 占用内存少, 连续的, 但是能存的数据量有限, 而quicklist兼具链表和ziplist的优势. 1. 6 Redis数据结构-SkipList\r不论是quicklist 还是ziplist 查找收尾节点很快, 但是中间节点较慢 skipList中可以存储 score和ele元素 SkipList（跳表）首先是链表，但与传统链表相比有几点差异： 元素按照升序排列存储 节点可能包含多个指针，指针跨度不同。\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653986771309.png](Java/Java Web/Redis/files/86e141862704658dac5b05eea526a7e2_MD5.png)\nSkipList（跳表）首先是链表，但与传统链表相比有几点差异： 元素按照升序排列存储 节点可能包含多个指针，指针跨度不同。\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653986813240.png](Java/Java Web/Redis/files/bc2f4f40d19b2ba7763d0482e0521c92_MD5.png)\nSkipList（跳表）首先是链表，但与传统链表相比有几点差异： 元素按照升序排列存储 节点可能包含多个指针，指针跨度不同。\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653986877620.png](Java/Java Web/Redis/files/f49aa0b6df17a5248614bcfaef442c75_MD5.png)\n总结\nSkipList的特点：\n跳跃表是一个双向链表，每一个节点都包含score和ele值 节点按照score值排序，score值一样则按照ele字典排序 每个节点都可以包含多层指针，层数是1到32之间的随机数 不同层指针到下一个节点的跨度不同，层级越高，跨度越大 增删改查效率与红黑树基本一致，实现却更简单 1.7 Redis数据结构-RedisObject\rRedis中的任意数据类型的键和值都会被封装为一个RedisObject，也叫做Redis对象，源码如下：\n1、什么是redisObject： 从Redis的使用者的角度来看，⼀个Redis节点包含多个database（非cluster模式下默认是16个，cluster模式下只能是1个），而一个database维护了从key space到object space的映射关系。这个映射关系的key是string类型，⽽value可以是多种数据类型，比如： string, list, hash、set、sorted set等。我们可以看到，key的类型固定是string，而value可能的类型是多个。 ⽽从Redis内部实现的⾓度来看，database内的这个映射关系是用⼀个dict来维护的。dict的key固定用⼀种数据结构来表达就够了，这就是动态字符串sds。而value则比较复杂，为了在同⼀个dict内能够存储不同类型的value，这就需要⼀个通⽤的数据结构，这个通用的数据结构就是robj，全名是redisObject。\n![image-20230530153819620](Java/Java Web/Redis/files/c893bb5b623584bb6fbee6c5ef45837e_MD5.png)\nRedis的编码方式\nRedis中会根据存储的数据类型不同，选择不同的编码方式，共包含11种不同类型：\n编号 编码方式 说明 0 OBJ_ENCODING_RAW raw编码动态字符串 1 OBJ_ENCODING_INT long类型的整数的字符串 2 OBJ_ENCODING_HT hash表（字典dict） 3 OBJ_ENCODING_ZIPMAP 已废弃 4 OBJ_ENCODING_LINKEDLIST 双端链表 5 OBJ_ENCODING_ZIPLIST 压缩列表 6 OBJ_ENCODING_INTSET 整数集合 7 OBJ_ENCODING_SKIPLIST 跳表 8 OBJ_ENCODING_EMBSTR embstr的动态字符串 9 OBJ_ENCODING_QUICKLIST 快速列表 10 OBJ_ENCODING_STREAM Stream流 五种数据结构\nRedis中会根据存储的数据类型不同，选择不同的编码方式。每种数据类型的使用的编码方式如下：\n数据类型 编码方式 OBJ_STRING int、embstr、raw OBJ_LIST LinkedList和ZipList(3.2以前)、QuickList（3.2以后） OBJ_SET intset、HT OBJ_ZSET ZipList、HT、SkipList OBJ_HASH ZipList、HT 1.8 Redis数据结构-String\rString是Redis中最常见的数据存储类型：\n其基本编码方式是RAW，基于简单动态字符串（SDS）实现，存储上限为512mb。\n如果存储的SDS长度小于44字节，则会采用EMBSTR编码，此时object head与SDS是一段连续空间。申请内存时只需要调用一次内存分配函数，效率更高。\n（1）底层实现⽅式：动态字符串sds 或者 long String的内部存储结构⼀般是sds（Simple Dynamic String，可以动态扩展内存），但是如果⼀个String类型的value的值是数字，那么Redis内部会把它转成long类型来存储，从⽽减少内存的使用。\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653987103450.png](Java/Java Web/Redis/files/08de211e46a011010f411bc39f75d6db_MD5.png)\n如果存储的字符串内存大小是小于44个字节的那么，就会采用的是EMBSTR编码\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653987159575.png](Java/Java Web/Redis/files/22601e962fb327e07a7740a9ad374edd_MD5.png)\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653987172764.png](Java/Java Web/Redis/files/a83f14f71e1125dd53c776960c8915db_MD5.png)\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653987202522.png](Java/Java Web/Redis/files/02a8b1eaf2b790e939523cf6511dacaa_MD5.png)\n确切地说，String在Redis中是⽤⼀个robj来表示的。\n用来表示String的robj可能编码成3种内部表⽰：OBJ_ENCODING_RAW，OBJ_ENCODING_EMBSTR，OBJ_ENCODING_INT。 其中前两种编码使⽤的是sds来存储，最后⼀种OBJ_ENCODING_INT编码直接把string存成了long型。 在对string进行incr, decr等操作的时候，如果它内部是OBJ_ENCODING_INT编码，那么可以直接行加减操作；如果它内部是OBJ_ENCODING_RAW或OBJ_ENCODING_EMBSTR编码，那么Redis会先试图把sds存储的字符串转成long型，如果能转成功，再进行加减操作。对⼀个内部表示成long型的string执行append, setbit, getrange这些命令，针对的仍然是string的值（即⼗进制表示的字符串），而不是针对内部表⽰的long型进⾏操作。比如字符串”32”，如果按照字符数组来解释，它包含两个字符，它们的ASCII码分别是0x33和0x32。当我们执行命令setbit key 7 0的时候，相当于把字符0x33变成了0x32，这样字符串的值就变成了”22”。⽽如果将字符串”32”按照内部的64位long型来解释，那么它是0x0000000000000020，在这个基础上执⾏setbit位操作，结果就完全不对了。因此，在这些命令的实现中，会把long型先转成字符串再进行相应的操作。\n1.9 Redis数据结构-List\rRedis的List类型可以从首、尾操作列表中的元素：\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653987240622.png](Java/Java Web/Redis/files/b0a7e6fee5b88a2fc3c2068bde63fe88_MD5.png)\n哪一个数据结构能满足上述特征？\nLinkedList ：普通链表，可以从双端访问，内存占用较高，内存碎片较多 ZipList ：压缩列表，可以从双端访问，内存占用低，存储上限低 QuickList：LinkedList + ZipList，可以从双端访问，内存占用较低，包含多个ZipList，存储上限高 Redis的List结构类似一个双端链表，可以从首、尾操作列表中的元素：\n在3.2版本之前，Redis采用ZipList和LinkedList来实现List，当元素数量小于512并且元素大小小于64字节时采用ZipList编码，超过则采用LinkedList编码。\n在3.2版本之后，Redis统一采用QuickList来实现List：\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653987313461.png](Java/Java Web/Redis/files/bccc7e7ddcb51151d1fc583478517ac3_MD5.png)\n![image-20230414125542661](Java/Java Web/Redis/files/e7a6a760e2a1ececf103d01b6be56354_MD5.png)\n![image-20230414125604370](Java/Java Web/Redis/files/4ddc0e7073ceff320d83fce40b3d89c0_MD5.png)\n1.10 Redis数据结构-Set结构\rSet是Redis中的单列集合，满足下列特点：\n不保证有序性 保证元素唯一 求交集、并集、差集 ![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653987342550.png](Java/Java Web/Redis/files/0b885baf9d04dd1a33a0af82899e6b29_MD5.png)\n可以看出，Set对查询元素的效率要求非常高，思考一下，什么样的数据结构可以满足？ HashTable，也就是Redis中的Dict，不过Dict是双列集合（可以存键、值对）\nSet是Redis中的集合，不一定确保元素有序，可以满足元素唯一、查询效率要求极高。 为了查询效率和唯一性，set采用HT编码（Dict）。Dict中的key用来存储元素，value统一为null。 当存储的所有数据都是整数，并且元素数量不超过set-max-intset-entries时，Set会采用IntSet编码，以节省内存\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653987388177.png](Java/Java Web/Redis/files/6c7999666df7f2e976067dc3771fe36e_MD5.png)\n![image-20230414201438824](Java/Java Web/Redis/files/35a96776b434273472ecb4f5fc868a38_MD5.png)\n一开始全是int, 所以使用intset存储 插入 \u0026ldquo;m1\u0026rdquo;, 破坏了全是整数的结构 新建dict结构(包含两个hashtable, 每个table都是一个 dictEntry数组) key是set中要存的值, value 是null即可 1.11 Redis数据结构-ZSET\rZSet也就是SortedSet，其中每一个元素都需要指定一个score值和member值：\n可以根据score值排序后 member必须唯一 可以根据member查询分数 ![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653992091967.png](Java/Java Web/Redis/files/6d78fa43b0e50e6342126f81c7814fdb_MD5.png)\n因此，zset底层数据结构必须满足键值存储、键必须唯一、可排序这几个需求。之前学习的哪种编码结构可以满足？\nSkipList：可以排序，并且可以同时存储score和ele值（member）, 可以通过score 查询ele, 但是不能根据ele查找score HT（Dict）：可以键值存储，并且可以根据key找value zset 使用了skipList 和dict ![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653992121692.png](Java/Java Web/Redis/files/11d47cb793f4da74a126e29e53a4b266_MD5.png)\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653992172526.png](Java/Java Web/Redis/files/e6031674e332663492dfadac77608765_MD5.png)\n当元素数量不多时，HT和SkipList的优势不明显，而且更耗内存。因此zset还会采用ZipList结构来节省内存，不过需要同时满足两个条件：\n元素数量小于zset_max_ziplist_entries，默认值128 每个元素都小于zset_max_ziplist_value字节，默认值64 ziplist本身没有排序功能，而且没有键值对的概念，因此需要有zset通过编码实现：\nZipList是连续内存，因此score和element是紧挨在一起的两个entry， element在前，score在后(当我们查询的时候就可以直接遍历即可，当我们要找m1的score，只需要找到m1在找下一个即可) score越小越接近队首，score越大越接近队尾，按照score值升序排列 ![image-20230415085252891](Java/Java Web/Redis/files/417a842e0aefeb4d5b9c187972c1a10e_MD5.png)\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653992238097.png](Java/Java Web/Redis/files/5d3c6a473a9b61b209b829cb61cca091_MD5.png)\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653992299740.png](Java/Java Web/Redis/files/a3a3442619cd8efba39bfd564c9e8436_MD5.png)\n1.12 Redis数据结构-Hash\rHash结构与Redis中的Zset非常类似：\n都是键值存储 都需求根据键获取值 键必须唯一 区别如下：\nzset的键是member，值是score；hash的键和值都是任意值 zset要根据score排序；hash则无需排序 底层实现方式：压缩列表ziplist 或者 字典dict 当Hash中数据项比较少的情况下，Hash底层才⽤压缩列表ziplist进⾏存储数据，随着数据的增加，底层的ziplist就可能会转成dict，具体配置如下：\nhash-max-ziplist-entries 512\nhash-max-ziplist-value 64\n当满足上面两个条件其中之⼀的时候，Redis就使⽤dict字典来实现hash。 Redis的hash之所以这样设计，是因为当ziplist变得很⼤的时候，它有如下几个缺点：\n每次插⼊或修改引发的realloc操作会有更⼤的概率造成内存拷贝，从而降低性能。 ⼀旦发生内存拷贝，内存拷贝的成本也相应增加，因为要拷贝更⼤的⼀块数据。 当ziplist数据项过多的时候，在它上⾯查找指定的数据项就会性能变得很低，因为ziplist上的查找需要进行遍历。 总之，ziplist本来就设计为各个数据项挨在⼀起组成连续的内存空间，这种结构并不擅长做修改操作。⼀旦数据发⽣改动，就会引发内存realloc，可能导致内存拷贝。\nhash结构如下：\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653992339937.png](Java/Java Web/Redis/files/b2a389d1cccd54b64e62f646d3902fa9_MD5.png)\nzset集合如下：\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653992360355.png](Java/Java Web/Redis/files/785f484571c1d68405057f636301d240_MD5.png)\n因此，Hash底层采用的编码与Zset也基本一致，只需要把排序有关的SkipList去掉即可：\nHash结构默认采用ZipList编码，用以节省内存。 ZipList中相邻的两个entry 分别保存field和value\n当数据量较大时，Hash结构会转为HT编码，也就是Dict，触发条件有两个：\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653992413406.png](Java/Java Web/Redis/files/b438dc61ccfcdd70970bb1c777e5390b_MD5.png)\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/image-20230416082505091.png](Java/Java Web/Redis/files/ab9d42b7b0bf47a11fab0c1df64f5fa5_MD5.png)\n![https://i-blog.csdnimg.cn/blog_migrate/ec5406b5772c9ee840bb5b649d55ebbb.png](Java/Java Web/Redis/files/20980813b0de09d9580dfff4177f358b_MD5.png)\n是否需要转换成HT\n![image-20230416082919346](Java/Java Web/Redis/files/934c7de712d58ad0924d3279674b68ba_MD5.png)\n这里就是在判断插入的时候key是否重复\n![image-20230416082937492](Java/Java Web/Redis/files/4c837e30ae6fee0418aa4dbf835e7bbc_MD5.png)\n1.13 数据结构总结\rIntSet（整数集合）：IntSet 是一种优化的数据结构，用于存储只包含整数值的集合。它在内存占用和性能方面都具有优势，适用于需要存储大量整数值的场景，比如计数器、唯一ID生成器等。 ![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653985149557.png](Java/Java Web/Redis/files/1432d90e6a1019a2fd0d1226db4740bb_MD5.png)\nDict（字典）：Dict 是 Redis 中的哈希表实现，用于存储键值对。它是 Redis 中很多数据结构的底层实现，适用于存储任意类型的键值对数据，常用于缓存数据、用户会话管理等。 ![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653985640422.png](Java/Java Web/Redis/files/78fe6ba99893e3cb0f24f9c3029b8387_MD5.png)\nZipList（压缩列表）：ZipList 是一种紧凑的、压缩的列表实现。它在元素较少且每个元素较小的情况下可以节省内存，并且支持快速的插入和删除操作。ZipList 适用于存储较小规模的列表数据。 ![image-20230412142739668](Java/Java Web/Redis/files/5e0f5e8d506aeba9b2493870aca63244_MD5.png)\nQuickList（快速列表）：QuickList 是一种特殊的列表实现，它将多个 ZipList 组合在一起形成一个链表结构。它可以在列表元素较多时节省内存，并且支持在列表两端进行插入和删除操作。QuickList 适用于存储较大规模的列表数据。 ![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653986718554.png](Java/Java Web/Redis/files/03a14cb278b314695eb7a359ec47ad65_MD5.png)\nSkipList（跳跃表）：SkipList 是一种有序的链表数据结构，可以快速进行元素的插入、删除和查找操作。SkipList 在有序集合 ZSet 的实现中被使用，适用于需要快速的有序集合操作的场景，比如排行榜、范围查询等。 ![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653986877620.png](Java/Java Web/Redis/files/f49aa0b6df17a5248614bcfaef442c75_MD5.png)\nRedisObject（Redis 对象）：RedisObject 是 Redis 中所有数据结构的基础对象，它封装了不同数据结构的值和类型信息。RedisObject 在 Redis 内部使用，作为数据结构的统一表示方式。 ![image-20230530153819620](Java/Java Web/Redis/files/c893bb5b623584bb6fbee6c5ef45837e_MD5.png)\nString（字符串）：String 是最基本的数据结构，可以存储字符串、整数或者浮点数。它的使用场景非常广泛，例如缓存数据、计数器、分布式锁等。\nList（列表）：List 是一个有序的字符串列表，可以在两端进行元素的插入和删除操作。它常用于实现队列、栈、消息发布与订阅等场景。\nSet（集合）：Set 是一个无序的、不重复的字符串集合，支持对集合进行交集、并集、差集等操作。它适用于存储唯一值的场景，比如社交网络中的关注列表、标签系统等。\nHash（哈希）：Hash 是一个键值对的无序散列表，可以存储多个字段和对应的值。Hash 适用于存储对象，例如用户信息、商品信息等，每个字段对应对象的一个属性。\nZSet（有序集合）：ZSet 是一个有序的字符串集合，每个成员都关联一个分数，根据分数进行排序。它常用于实现排行榜、优先级队列等场景。\n二、Redis网络模型\r2.1 用户空间和内核态空间\r服务器大多都采用Linux系统，这里我们以Linux为例来讲解:\nubuntu和Centos 都是Linux的发行版，发行版可以看成对linux包了一层壳，任何Linux发行版，其系统内核都是Linux。我们的应用都需要通过Linux内核与硬件交互\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653844970346.png](Java/Java Web/Redis/files/b9e3b0fd7fd69c98028863d4e0e12a97_MD5.png)\n用户的应用，比如redis，mysql等其实是没有办法去执行访问我们操作系统的硬件的，所以我们可以通过发行版的这个壳子去访问内核，再通过内核去访问计算机硬件\n![https://i-blog.csdnimg.cn/blog_migrate/dfbbdd69c589b79d5801c56e379f5e01.png](Java/Java Web/Redis/files/0ce160bb88100dc192b04dd436e382c8_MD5.png)\n计算机硬件包括，如cpu，内存，网卡等等，内核（通过寻址空间）可以操作硬件的，但是内核需要不同设备的驱动，有了这些驱动之后，内核就可以去对计算机硬件去进行 内存管理，文件系统的管理，进程的管理等等\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653896065386.png](Java/Java Web/Redis/files/fc64cea56f925be5a1de12dec84db4c7_MD5.png)\n我们想要用户的应用来访问，计算机就必须要通过对外暴露的一些接口，才能访问到，从而简介的实现对内核的操控，但是内核本身上来说也是一个应用，所以他本身也需要一些内存，cpu等设备资源，用户应用本身也在消耗这些资源，如果不加任何限制，用户去操作随意的去操作我们的资源，就有可能导致一些冲突，甚至有可能导致我们的系统出现无法运行的问题，因此我们需要把用户和内核隔离开\n进程的寻址空间划分成两部分：内核空间、用户空间\n什么是寻址空间呢？我们的应用程序也好，还是内核空间也好，都是没有办法直接去物理内存的，而是通过分配一些虚拟内存映射到物理内存中，我们的内核和应用程序去访问虚拟内存的时候，就需要一个虚拟地址，这个地址是一个无符号的整数，比如一个32位的操作系统，他的带宽就是32，他的虚拟地址就是2的32次方，也就是说他寻址的范围就是0~2的32次方， 这片寻址空间对应的就是2的32个字节，就是4GB，这个4GB，会有3个GB分给用户空间，会有1GB给内核系统\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653896377259.png](Java/Java Web/Redis/files/aacaa30443a5262d4f84f3bba63a10b1_MD5.png)\n在linux中，他们权限分成两个等级，0和3，用户空间只能执行受限的命令（Ring3），而且不能直接调用系统资源，必须通过内核提供的接口来访问内核空间可以执行特权命令（Ring0），调用一切系统资源，所以一般情况下，用户的操作是运行在用户空间，而内核运行的数据是在内核空间的，而有的情况下，一个应用程序需要去调用一些特权资源，去调用一些内核空间的操作，所以此时他俩需要在用户态和内核态之间进行切换。\n比如：\nLinux系统为了提高IO效率，会在用户空间和内核空间都加入缓冲区：\n写数据时，要把用户缓冲数据拷贝到内核缓冲区，然后写入设备\n读数据时，要从设备读取数据到内核缓冲区，然后拷贝到用户缓冲区\n针对这个操作：我们的用户在写读数据时，会去向内核态申请，想要读取内核的数据，而内核数据要去等待驱动程序从硬件上读取数据，当从磁盘上加载到数据之后，内核会将数据写入到内核的缓冲区中，然后再将数据拷贝到用户态的buffer中，然后再返回给应用程序，整体而言，速度慢，就是这个原因，为了加速，我们希望read也好，还是wait for data也最好都不要等待，或者时间尽量的短。\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653896687354.png](Java/Java Web/Redis/files/78ffe24f5845ebc3a8aa8985266dcba6_MD5.png)\n2.2 网络模型-阻塞IO\r在《UNIX网络编程》一书中，总结归纳了5种IO模型：\n阻塞IO（Blocking IO） 非阻塞IO（Nonblocking IO） IO多路复用（IO Multiplexing） 信号驱动IO（Signal Driven IO） 异步IO（Asynchronous IO） 应用程序想要去读取数据，他是无法直接去读取磁盘数据的，他需要先到内核里边去等待内核操作硬件拿到数据，这个过程就是1，是需要等待的，等到内核从磁盘上把数据加载出来之后，再把这个数据写给用户的缓存区，这个过程是2，如果是阻塞IO，那么整个过程中，用户从发起读请求开始，一直到读取到数据，都是一个阻塞状态。\n整体流程如下图：\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653897115346.png](Java/Java Web/Redis/files/d2eeaa216036a4cf190a386db8b21ef8_MD5.png)\n用户去读取数据时，会去先发起recvform一个命令，去尝试从内核上加载数据，如果内核没有数据，那么用户就会等待，此时内核会去从硬件上读取数据，内核读取数据之后，会把数据拷贝到用户态，并且返回ok，整个过程，都是阻塞等待的，这就是阻塞IO\n总结如下：\n顾名思义，阻塞IO就是两个阶段都必须阻塞等待：\n阶段一：\n用户进程尝试读取数据（比如网卡数据） 此时数据尚未到达，内核需要等待数据 此时用户进程也处于阻塞状态 阶段二：\n数据到达并拷贝到内核缓冲区，代表已就绪 将内核数据拷贝到用户缓冲区 拷贝过程中，用户进程依然阻塞等待 拷贝完成，用户进程解除阻塞，处理数据 可以看到，阻塞IO模型中，用户进程在两个阶段都是阻塞状态。\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653897270074.png](Java/Java Web/Redis/files/3f15e459e251efa604297fa74fa7e8f4_MD5.png)\n2.3 网络模型-非阻塞IO\r顾名思义，非阻塞IO的recvfrom操作会立即返回结果而不是阻塞用户进程。\n阶段一：\n用户进程尝试读取数据（比如网卡数据） 此时数据尚未到达，内核需要等待数据 返回异常给用户进程 用户进程拿到error后，再次尝试读取 循环往复，直到数据就绪 阶段二：\n将内核数据拷贝到用户缓冲区 拷贝过程中，用户进程依然阻塞等待 拷贝完成，用户进程解除阻塞，处理数据 可以看到，非阻塞IO模型中，用户进程在第一个阶段是非阻塞，第二个阶段是阻塞状态。虽然是非阻塞，但性能并没有得到提高。而且忙等机制会导致CPU空转，CPU使用率暴增。 ![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653897490116.png](Java/Java Web/Redis/files/64720af8ba1e16d3d1a765f60d0b704c_MD5.png)\n2.4 网络模型-IO多路复用\r无论是阻塞IO还是非阻塞IO，用户应用在一阶段都需要调用recvfrom来获取数据，差别在于无数据时的处理方案：\n如果调用recvfrom时，恰好没有数据，阻塞IO会使CPU阻塞，非阻塞IO使CPU空转，都不能充分发挥CPU的作用。 如果调用recvfrom时，恰好有数据，则用户进程可以直接进入第二阶段，读取并处理数据 所以怎么看起来以上两种方式性能都不好\n而在单线程情况下，只能依次处理IO事件，如果正在处理的IO事件恰好未就绪（数据不可读或不可写），线程就会被阻塞，所有IO事件都必须等待，性能自然会很差。\n就比如服务员给顾客点餐，分两步：\n顾客思考要吃什么（等待数据就绪） 顾客想好了，开始点餐（读取数据） ![image-20230416092848794](Java/Java Web/Redis/files/6383c7752030bc769c7c619b948dd73b_MD5.png)\n要提高效率有几种办法？\n方案一：增加更多服务员（多线程） 方案二：不排队，谁想好了吃什么（数据就绪了），服务员就给谁点餐（用户应用就去读取数据） 那么问题来了：用户进程如何知道内核中数据是否就绪呢？\n所以接下来就需要详细的来解决多路复用模型是如何知道到底怎么知道内核数据是否就绪的问题了\n这个问题的解决依赖于提出的文件描述符。\n文件描述符（File Descriptor）：简称FD，是一个从0 开始的无符号整数，用来关联Linux中的一个文件。在Linux中，一切皆文件，例如常规文件、视频、硬件设备等，当然也包括网络套接字（Socket）。\n通过FD，我们的网络模型可以利用一个线程监听多个FD，并在某个FD可读、可写时得到通知，从而避免无效的等待，充分利用CPU资源。\n阶段一：\n用户进程调用select，指定要监听的FD集合 核监听FD对应的多个socket 任意一个或多个socket数据就绪则返回readable 此过程中用户进程阻塞 阶段二：\n用户进程找到就绪的socket 依次调用recvfrom读取数据 内核将数据拷贝到用户空间 用户进程处理数据 当用户去读取数据的时候，不再去直接调用recvfrom了，而是调用select的函数，select函数会将需要监听的数据交给内核，由内核去检查这些数据是否就绪了，如果说这个数据就绪了，就会通知应用程序数据就绪，然后来读取数据，再从内核中把数据拷贝给用户态，完成数据处理，如果N多个FD一个都没处理完，此时就进行等待。\n用IO复用模式，可以确保去读数据的时候，数据是一定存在的，他的效率比原来的阻塞IO和非阻塞IO性能都要高\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653898691736.png](Java/Java Web/Redis/files/ac6faff8f5360cc92148596996edb4f2_MD5.png)\nIO多路复用：是利用单个线程来同时监听多个FD，并在某个FD可读、可写时得到通知，从而避免无效的等待，充分利用CPU资源。不过监听FD的方式、通知的方式又有多种实现，常见的有：\nselect poll epoll 其中select和poll相当于是当被监听的数据准备好之后，他会把你监听的FD整个数据都发给你，你需要到整个FD中去找，哪些是处理好了的，需要通过遍历的方式，所以性能也并不是那么好\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/image-20230416093057739.png](Java/Java Web/Redis/files/3bdc91d8f74cad66a281f3169dc8d491_MD5.png)\n就类似于有人想好吃什么，按下了灯泡，服务员灯亮了，但是服务员却不知道是谁准备好点餐了，需要一个一个的询问。\n而epoll，则相当于内核准备好了之后，他会把准备好的数据，直接发给你，咱们就省去了遍历的动作。\n2.4.1 网络模型-IO多路复用-select方式\rselect是Linux最早是由的I/O多路复用技术：\n简单说，就是我们把需要处理的数据封装成FD，然后在用户态时创建一个fd的集合（这个集合的大小是要监听的那个FD的最大值+1，但是大小整体是有限制的 ），这个集合的长度大小是有限制的，同时在这个集合中，标明出来我们要控制哪些数据，\n比如要监听的数据，是1,2,5三个数据，此时会执行select函数，然后将整个fd发给内核态，内核态会去遍历用户态传递过来的数据，如果发现这里边都数据都没有就绪，就休眠，直到有数据准备好时，就会被唤醒，唤醒之后，再次遍历一遍，看看谁准备好了，然后再将处理掉没有准备好的数据，最后再将这个FD集合写回到用户态中去，此时用户态就知道了，奥，有人准备好了，但是对于用户态而言，并不知道谁处理好了，所以用户态也需要去进行遍历，然后找到对应准备好数据的节点，再去发起读请求，我们会发现，这种模式下他虽然比阻塞IO和非阻塞IO好，但是依然有些麻烦的事情， 比如说频繁的传递fd集合，频繁的去遍历FD等问题\n如下图: 125用位图表示出来, 传递到内核 内核等到收到就绪通知后遍历检查哪些就绪了, 发现是1, 将位图中1置为1, 其余为零(表示未就绪), 传递给用户空间位图(用户空间得到位图), 用户要再遍历一遍才能知道哪些就绪了 ![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653900022580.png](Java/Java Web/Redis/files/90a2a31cdbbd1d404c937f3fc823f1ca_MD5.png) ![image-20230531162129553](Java/Java Web/Redis/files/d34de1aa5924df14001f551cf9634932_MD5.png)\nseletc模式存在的问题：\n需要将整个fd_set 用户空间拷贝到内核空间，select:结束还要再次拷贝回用户空间 select无法得知具体是哪个fd就绪，需要遍历整个fd_set fd set监听的fd数量不能超过1024(由fds_bits 长度决定) 2.4.2 网络模型-IO多路复用模型-poll模式\rpoll模式对select模式做了简单改进，但性能提升不明显，部分关键代码如下：\nIO流程：\n创建pollfd数组，向其中添加关注的fd信息，数组大小自定义 调用poll函数，将pollfd数组拷贝到内核空间，转链表存储，无上限 内核遍历fd，判断是否就绪 数据就绪或超时后，拷贝pollfd数组到用户空间，返回就绪fd数量n 用户进程判断n是否大于0,大于0则遍历pollfd数组，找到就绪的fd 与select对比：\nselect模式中的fd_set大小固定为1024，而pollfd在内核中采用链表，理论上无上限 监听FD越多，每次遍历消耗时间也越久，性能反而会下降 ![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653900721427.png](Java/Java Web/Redis/files/872395cd58bfeb23419163b1fa318ea0_MD5.png)\n2.4.3 网络模型-IO多路复用模型-epoll函数\repoll模式是对select和poll的改进，它提供了三个函数：\n![image-20230418114320169](Java/Java Web/Redis/files/ed30a95eac8b0546559f491b678e3cc5_MD5.png)\nepoll_ctl中的epfd就是表明要将监听的fd添加到那个一个eventepoll中\n第一个是：eventpoll的函数，他内部包含两个东西\n一个是：\n1、红黑树–\u0026gt; 记录的事要监听的FD\n2、一个是链表-\u0026gt;一个链表，记录的是就绪的FD\n紧接着调用epoll_ctl操作，将要监听的数据添加到红黑树上去，并且给每个fd设置一个监听函数，这个函数会在fd数据就绪时触发，就是准备好了，现在就把fd把数据添加到list_head中去\n3、调用epoll_wait函数\n就去等待，在用户态创建一个空的events数组，当就绪之后，我们的回调函数会把数据添加到list_head中去，当调用这个函数的时候，会去检查list_head，当然这个过程需要参考配置的等待时间，可以等一定时间，也可以一直等， 如果在此过程中，检查到了list_head中有数据会将数据添加到链表中，此时将数据放入到events数组中，并且返回对应的操作的数量，用户态的此时收到响应后，从events中拿到对应准备好的数据的节点，再去调用方法去拿数据。\n![image-20230418114257508](Java/Java Web/Redis/files/20657688f9e2651c57d0502be8997f45_MD5.png)\n小总结：\nselect模式存在的三个问题：\n能监听的FD最大不超过1024 每次select都需要把所有要监听的FD都拷贝到内核空间 每次都要遍历所有FD来判断就绪状态 poll模式的问题：\npoll利用链表解决了select中监听FD上限的问题，但依然要遍历所有FD，如果监听较多，性能会下降 epoll模式中如何解决这些问题的？\n基于epoll实例中的红黑树保存要监听的FD，理论上无上限，而且增删改查效率都非常高 每个FD只需要执行一次epoll_ctl添加到红黑树，以后每次epol_wait无需传递任何参数，无需重复拷贝FD到内核空间 利用ep_poll_callback机制来监听FD状态，无需遍历所有FD，因此性能不会随监听的FD数量增多而下降 1、网络模型-epoll中的ET和LT\r当FD有数据可读时，我们调用epoll_wait（或者select、poll）可以得到通知。但是事件通知的模式有两种：\nLevelTriggered：简称LT，也叫做水平触发。只要某个FD中有数据可读，每次调用epoll_wait都会得到通知。 EdgeTriggered：简称ET，也叫做边沿触发。只有在某个FD有状态变化时，调用epoll_wait才会被通知。 举个栗子：\n假设一个客户端socket对应的FD已经注册到了epoll实例中 客户端socket发送了2kb的数据 服务端调用epoll_wait，得到通知说FD就绪 服务端从FD读取了1kb(没有完全读取完, 还剩1kb) 数据回到步骤3（再次调用epoll_wait，形成循环） 结论\n如果我们采用LT模式，因为FD中仍有1kb数据，则第⑤步依然会返回结果，并且得到通知 如果我们采用ET模式，因为第③步已经消费了FD可读事件，第⑤步FD状态没有变化，因此epoll_wait不会返回，数据无法读取，客户端响应超时。\n2、网络模型-基于epoll的服务器端流程\r我们来梳理一下这张图\n服务器启动以后，服务端会去调用epoll_create，创建一个epoll实例，epoll实例中包含两个数据\n1、红黑树（为空）：rb_root 用来去记录需要被监听的FD\n2、链表（为空）：list_head，用来存放已经就绪的FD\n创建好了之后，会去调用epoll_ctl函数，此函数会会将需要监听的数据添加到rb_root中去，并且对当前这些存在于红黑树的节点设置回调函数，当这些被监听的数据一旦准备完成，就会被调用，而调用的结果就是将红黑树的fd添加到list_head中去(但是此时并没有完成)\n3、当第二步完成后，就会调用epoll_wait函数，这个函数会去校验是否有数据准备完毕（因为数据一旦准备就绪，就会被回调函数添加到list_head中），在等待了一段时间后(可以进行配置)，如果等够了超时时间，则返回没有数据，如果有，则进一步判断当前是什么事件，如果是建立连接时间，则调用accept() 接受客户端socket，拿到建立连接的socket，然后建立起来连接，如果是其他事件，则把数据进行写出\n新客户端连接是ssfd可读, 客户端请求是 accept的socket的fd可读 ![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653902845082.png](Java/Java Web/Redis/files/573b34935c149202378fedbd84ea4b7c_MD5.png)\n其中当获取到客户端的socket之后还要将对应的客户端soket的fd注册到rb_root中，去查看是否有客户端发送请求\n2.4.4 IO多路复用-三种方式之间的对比\r方式 select poll epoll 平台 可用于所有平台 可用于所有平台 仅限于Linux 2.6及更高版本 性能 随着文件描述符数量增加，性能下降 随着文件描述符数量增加，性能下降 高性能，支持大量并发连接 优点 实现简单，跨平台支持好 没有文件描述符数限制，解决了select模型的缺点 只返回活跃的文件描述符，无需遍历整个文件描述符集合 缺点 遍历整个文件描述符集合，效率低下，最大文件描述符数限制（通常是1024） 遍历整个文件描述符集合，效率较低 仅在Linux平台上可用，不具备跨平台性 文件描述符限制 有最大文件描述符限制 无最大文件描述符限制 无最大文件描述符限制 编程简易性 相对较简单 相对较简单 相对复杂 支持的事件类型 仅支持读写事件 支持读写事件和异常事件 支持读写事件和异常事件 可扩展性 受文件描述符数量限制（低） 受文件描述符数量限制（中等） 可以轻松处理大量并发连接（高） 内存消耗 随着文件描述符数量增加，内存消耗增加 随着文件描述符数量增加，内存消耗增加 内存消耗较小 实现原理 基于轮询 基于轮询 基于事件通知 事件触发模式 水平触发 水平触发 边缘触发和水平触发 处理效率 低 中等 高 使用场景 适用于连接数较少的情况，适合于简单的应用程序或测试用途 适用于连接数较多，但不是非常大的情况下，适合于中等规模的应用程序 适用于高并发、连接数非常大的情况，适合于大规模的应用程序，具有最佳的性能和扩展性 补充说明：\nepoll的实现相对于select和poll而言，更加高效，在高并发请求下，epoll可以支持上万个连接的读写操作。 select采用轮询的方式查找有数据可读写的socket，效率比较低，并且随着监控的文件描述符数量的增加，其效率会快速下降。 poll改进了select的问题，用链表来存储文件描述符，但是它还是采用了轮询的方式，所以效率依然不高，但是相对于select而言，poll的可扩展性更好一些。 epoll是基于事件通知的方式，因此可以避免无效遍历，从而提高了效率。 在事件触发模式方面，epoll支持边缘触发和水平触发，灵活性更高，能够满足更多场景的需求。 在使用难度方面，select最简单易用，poll稍微复杂一点，而epoll则需要进行更加复杂的操作。 2.5 网络模型-信号驱动\r信号驱动IO是与内核建立SIGIO的信号关联并设置回调，当内核有FD就绪时，会发出SIGIO信号通知用户，期间用户应用可以执行其它业务，无需阻塞等待。\n阶段一：\n用户进程调用sigaction，注册信号处理函数 内核返回成功，开始监听FD 用户进程不阻塞等待，可以执行其它业务 当内核数据就绪后，回调用户进程的SIGIO处理函数 阶段二：\n收到SIGIO回调信号 调用recvfrom，读取 内核将数据拷贝到用户空间 用户进程处理数据 ![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653911776583.png](Java/Java Web/Redis/files/a65fa8a15080e2d549902efda7470f54_MD5.png)\n当有大量IO操作时，信号较多，SIGIO处理函数不能及时处理可能导致信号队列溢出，而且内核空间与用户空间的频繁信号交互性能也较低。\n1、异步IO\r这种方式，不仅仅是用户态在试图读取数据后，不阻塞，而且当内核的数据准备完成后，也不会阻塞\n他会由内核将所有数据处理完成后，由内核将数据写入到用户态中，然后才算完成，所以性能极高，不会有任何阻塞，全部都由内核完成，可以看到，异步IO模型中，用户进程在两个阶段都是非阻塞状态。\nIO操作是同步还是异步，关键看数据在内核空间与用户空间的拷贝过程（数据读写的IO操作），也就是阶段二是同步还是异步\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653912219712.png](Java/Java Web/Redis/files/662cf1c1f76ec5347a6ba616a3cd7c02_MD5.png)\n2.6 网络模型-Redis是单线程的吗？为什么使用单线程\rRedis到底是单线程还是多线程？\r如果仅仅聊Redis的核心业务部分（命令处理），答案是单线程\n如果是聊整个Redis，那么答案就是多线程\n在Redis版本迭代过程中，在两个重要的时间节点上引入了多线程的支持：\nRedis v4.0：引入多线程异步处理一些耗时较久的任务，例如异步删除命令unlink Redis v6.0 ：在核心网络模型中引入多线程，进一步提高对多核cpu的利用率 因此，对于Redis的核心网络模型，在Redis 6.0之前确实都是单线程。是利用epoll（Linux系统）这样的IO多路复用技术在事件循环中不断处理客户端情况。\n为什么Redis要选择单线程？\n抛开持久化不谈，Redis是纯内存操作，执行速度非常快，它的性能瓶颈是网络延迟而不是执行速度，因此多线程并不会带来巨大的性能提升。 多线程会导致过多的上下文切换，带来不必要的开销 引入多线程会面临线程安全问题，必然要引入线程锁这样的安全手段，实现复杂度增高，而且性能也会大打折扣 2.7 Redis的单线程模型-Redis单线程和多线程网络模型变更\rRedis通过IO多路复用来提高网络性能，并且支持各种不同的多路复用实现，并且将这些实现进行封装， 提供了统一的高性能事件库API库 AE：\n![image-20230419131448449](Java/Java Web/Redis/files/5098246cc2d2f050393926c3aa09d8c2_MD5.png) ![image-20230419131457588](Java/Java Web/Redis/files/8605defecfc07eb27e6ea1620cbb57f6_MD5.png)\n看一下Redis单线程网络模型的整个流程\n现在main函数中进行函数的初始化\n![image-20230419131915475](Java/Java Web/Redis/files/02bfa3bc0d9066d9cfb4017e371481e5_MD5.png)\ncreateSocketAcceptHandler他做了两件事情，第一个就是监听我们的socket，第二就是给我们Socket上发生的事件做一个处理器，以前我们直接注册上去就完了在真正发生事件的时候再去做处理，现在是提前准备好一个Socket事件发生的时候做处理的准备。下面这张图就是具体的处理Socket事件的处理方法\n![image-20230419131947527](Java/Java Web/Redis/files/b309f4d4e1b98f1613f1ceb8fbe38eae_MD5.png)\n接受到客户端的请求，并且关联客户端的fd，监听客户端socket的fd，connSetReadHandler(conn, readQueryFromClient)这个方法就是用来监听客户端的socket的读事件（所以第二参数就是读处理器），其中参数conn，已经赋值给了fd，所以实际传递的还是fd\nredis单线程网络模型的整个流程：\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/image-20230419133632720.png](Java/Java Web/Redis/files/2fcb9194eb6ccae697b61fea0299f942_MD5.png)\n创建serverSocket，并且会创建一个对应的fd，这个fd就会注册到我们的Eventloop上，同时我们还给serverSocket绑定一个处理器，tcpAccepthandler,专门处理serverSocket上的可读事件，然后就是去调用beforesleep,调用ApiPoll等待就绪，如果当serverSocket就绪之后就会调用tcpAccepthandler,这个时候就表明有客户端连上来了，这个时候又去执行这个循环事件，但是这个时候的读事件可能就不止一个了（有可能是客户端的），于是就给客户端绑定一个readQueryFromClient来处理客户端的读命令，我们知道在原来的epoll中这个时候就是读的是客户端的数据，那么在redis中是怎么操作的呢，下图就是readQueryFromClient的部分源码\n![image-20230419135127111](Java/Java Web/Redis/files/0ea73601fd5e79658841bd106ee76475_MD5.png)\n即readQueryFromClient将每一个客户端的请求封装成为一个client，每一个client都一个querybuf，接下来读出请求中的命令放到querybuf中，然后调用processInputBuffer解析缓冲区的字符变成redis命令放到agv的数组当中，然后读出argv中的第0字符，去找到他是属于什么命令，执行，在返回结果的时候他会先尝试向c-buf中写入数据，如果c-buf写不下了，那么他就会写道c-reply这个链表当中写，理论是容量无上限，然后将客户端添加到server.client_pending_write这个队列（是服务器已经定义好的队列），等待被写出，到这里readQueryFromClient函数就结束了，但是这个时候结果还没写出，还在队列当中。这个是时候beforesleep就开始发挥作用了\n以下是beforesleep的部分源码\n![image-20230420081046160](Java/Java Web/Redis/files/1f82a8c79d5b0ca1c6ab94c4a3347917_MD5.png)\n这里就是给每一个客户端都绑定了一个写处理器（sendReplyToClient）\n![image-20230419131235567](Java/Java Web/Redis/files/a6c9469e216f092d0da86f17b6627fcc_MD5.png)\n当我们的客户端想要去连接我们服务器，会去先到IO多路复用模型去进行排队，会有一个连接应答处理器，他会去接受读请求，然后又把读请求注册到具体模型中去，此时这些建立起来的连接，如果是客户端请求处理器去进行执行命令时，他会去把数据读取出来，然后把数据放入到client中， clinet去解析当前的命令转化为redis认识的命令，接下来就开始处理这些命令，从redis中的command中找到这些命令，然后就真正的去操作对应的数据了，当数据操作完成后，会去找到命令回复处理器，再由他将数据写出。\n多线程优化, io会有瓶颈(这里是网络io) 总结\rRedis是一个基于内存的数据存储系统，它使用单线程网络模型来处理客户端请求。下面是Redis单线程网络模型的执行流程：\n监听端口：Redis服务器开始监听指定的端口，等待客户端连接。 接收客户端连接：当有客户端请求连接到Redis服务器时，服务器会接受连接请求，并创建一个客户端套接字，用于与客户端通信。 接收命令：一旦客户端与服务器建立连接，客户端可以发送命令请求到服务器。Redis服务器通过套接字接收到客户端发送的命令。 命令解析：服务器会对接收到的命令进行解析，以确定客户端请求的具体操作。 执行命令：根据解析的结果，服务器会执行相应的命令操作。由于Redis使用单线程模型，每个命令都会按顺序依次执行，不会并发执行。 数据读写：在执行命令期间，如果需要读取或修改数据，服务器会从内存中读取数据或将修改后的数据写回内存。 命令回复：执行完命令后，服务器会将执行结果封装为响应，并通过套接字发送回客户端。 关闭连接：命令执行完成后，服务器会关闭与客户端的连接，等待下一个连接请求。 Redis单线程网络模型是指Redis服务器使用单个线程来处理所有客户端请求和命令操作。它的执行流程包括监听端口、接收客户端连接、接收命令、命令解析、执行命令、数据读写、命令回复和关闭连接。由于单线程的特性，Redis在处理请求时是顺序执行的，不会并发执行命令。这种模型简化了并发控制和线程同步的复杂性，但也限制了Redis服务器的处理能力。然而，通过高效利用CPU和异步IO操作，Redis仍然能够提供出色的性能和响应速度。\n三、Redis通信协议-RESP协议\rRedis是一个CS架构的软件，通信一般分两步（不包括pipeline和PubSub）：\n客户端（client）向服务端（server）发送一条命令\n服务端解析并执行命令，返回响应结果给客户端\n因此客户端发送命令的格式、服务端响应结果的格式必须有一个规范，这个规范就是通信协议。\n而在Redis中采用的是RESP（Redis Serialization Protocol）协议：\nRedis 1.2版本引入了RESP协议\nRedis 2.0版本中成为与Redis服务端通信的标准，称为RESP2\nRedis 6.0版本中，从RESP2升级到了RESP3协议，增加了更多数据类型并且支持6.0的新特性–客户端缓存\n但目前，默认使用的依然是RESP2协议，也是我们要学习的协议版本（以下简称RESP）。\n在RESP中，通过首字节的字符来区分不同数据类型，常用的数据类型包括5种：\n单行字符串：首字节是 ‘+’ ，后面跟上单行字符串，以CRLF（ \u0026quot;\\r\\n\u0026quot; ）结尾。例如返回\u0026quot;OK\u0026quot;： \u0026quot;+OK\\r\\n\u0026quot;\n错误（Errors）：首字节是 ‘-’ ，与单行字符串格式一样，只是字符串是异常信息，例如：\u0026quot;-Error message\\r\\n\u0026quot;\n数值：首字节是 ‘:’ ，后面跟上数字格式的字符串，以CRLF结尾。例如：\u0026quot;:10\\r\\n\u0026quot;\n多行字符串：首字节是 ‘$’ ，表示二进制安全的字符串，最大支持512MB：\n如果大小为0，则代表空字符串：\u0026quot;$0\\r\\n\\r\\n\u0026quot; , 第一个 \u0026ldquo;\\r\\n\u0026quot;是表示len字段结束了, 后面的 \u0026ldquo;\\r\\n\u0026quot;表示字符串结束\n如果大小为-1，则代表不存在：\u0026quot;$-1\\r\\n\u0026quot;\n数组：首字节是 ‘*’， 后面跟上数组元素个数，再跟上元素，元素数据类型不限：\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653982993020.png](Java/Java Web/Redis/files/c95d2bf1cc05af3d1a197b3dc0deb982_MD5.png)\n3.1 Redis通信协议-基于Socket自定义Redis的客户端\rRedis支持TCP通信，因此我们可以使用Socket来模拟客户端，与Redis服务端建立连接：\npublic class Main { static Socket s; static PrintWriter writer; static BufferedReader reader; public static void main(String[] args) { try { // 1.建立连接 String host = \u0026#34;127.0.0.1\u0026#34;; int port = 6379; s = new Socket(host, port); // 2.获取输出流、输入流 writer = new PrintWriter(new OutputStreamWriter(s.getOutputStream(), StandardCharsets.UTF_8)); reader = new BufferedReader(new InputStreamReader(s.getInputStream(), StandardCharsets.UTF_8)); // 3.发出请求 // 3.1.获取授权 auth 123321 sendRequest(\u0026#34;auth\u0026#34;, \u0026#34;123456 \u0026#34;); Object obj = handleResponse(); System.out.println(\u0026#34;obj = \u0026#34; + obj); // 3.2.set name 虎哥 sendRequest(\u0026#34;set\u0026#34;, \u0026#34;name\u0026#34;, \u0026#34;虎哥\u0026#34;); // 4.解析响应 obj = handleResponse(); System.out.println(\u0026#34;obj = \u0026#34; + obj); // 3.2.set name 虎哥 sendRequest(\u0026#34;get\u0026#34;, \u0026#34;name\u0026#34;); // 4.解析响应 obj = handleResponse(); System.out.println(\u0026#34;obj = \u0026#34; + obj); // 3.2.set name 虎哥 sendRequest(\u0026#34;mget\u0026#34;, \u0026#34;name\u0026#34;, \u0026#34;num\u0026#34;, \u0026#34;msg\u0026#34;); // 4.解析响应 obj = handleResponse(); System.out.println(\u0026#34;obj = \u0026#34; + obj); } catch (IOException e) { e.printStackTrace(); } finally { // 5.释放连接 try { if (reader != null) reader.close(); if (writer != null) writer.close(); if (s != null) s.close(); } catch (IOException e) { e.printStackTrace(); } } } private static Object handleResponse() throws IOException { // 读取首字节 int prefix = reader.read(); // 判断数据类型标示 switch (prefix) { case \u0026#39;+\u0026#39;: // 单行字符串，直接读一行 return reader.readLine(); case \u0026#39;-\u0026#39;: // 异常，也读一行 throw new RuntimeException(reader.readLine()); case \u0026#39;:\u0026#39;: // 数字 return Long.parseLong(reader.readLine()); case \u0026#39;$\u0026#39;: // 多行字符串 // 先读长度 int len = Integer.parseInt(reader.readLine()); if (len == -1) { return null; } if (len == 0) { return \u0026#34;\u0026#34;; } // 再读数据,读len个字节。我们假设没有特殊字符，所以读一行（简化） return reader.readLine(); case \u0026#39;*\u0026#39;: return readBulkString(); default: throw new RuntimeException(\u0026#34;错误的数据格式！\u0026#34;); } } private static Object readBulkString() throws IOException { // 获取数组大小 int len = Integer.parseInt(reader.readLine()); if (len \u0026lt;= 0) { return null; } // 定义集合，接收多个元素 List\u0026lt;Object\u0026gt; list = new ArrayList\u0026lt;\u0026gt;(len); // 遍历，依次读取每个元素 for (int i = 0; i \u0026lt; len; i++) { list.add(handleResponse()); } return list; } // set name 虎哥 private static void sendRequest(String ... args) { writer.println(\u0026#34;*\u0026#34; + args.length); for (String arg : args) { writer.println(\u0026#34;$\u0026#34; + arg.getBytes(StandardCharsets.UTF_8).length); writer.println(arg); } writer.flush(); } } 3.2 总结\rRESP（REdis Serialization Protocol）是Redis使用的通信协议，用于在客户端和服务器之间传输数据。RESP协议采用文本协议格式，具有简单、高效和可读性好的特点。下面是RESP协议的一些关键特点和示例：\n简单的数据类型： RESP协议支持以下几种数据类型：简单字符串（Simple Strings）、错误信息（Errors）、整数（Integers）、大块字符串（Bulk Strings）和数组（Arrays）。 每种数据类型都有对应的表示格式和解析规则。 固定格式： RESP协议的每个数据类型都以特定的前缀字符作为标识符。 简单字符串以\u0026rdquo;+“作为前缀，例如：\u0026quot;+OK\\r\\n\u0026quot;表示一个简单字符串\u0026quot;OK”。 错误信息以\u0026rdquo;-\u0026ldquo;作为前缀，例如：\u0026quot;-Error message\\r\\n\u0026quot;表示一个错误信息\u0026quot;Error message\u0026quot;。 整数以\u0026quot;:\u0026quot;作为前缀，例如：\u0026quot;:1000\\r\\n\u0026quot;表示一个整数1000。 大块字符串以\u0026quot;$\u0026quot;作为前缀，后跟字符串的字节数和实际字符串内容，例如：\u0026quot;$5\\r\\nHello\\r\\n\u0026quot;表示一个长度为5的字符串\u0026quot;Hello\u0026rdquo;。 数组以\u0026quot;*\u0026quot;作为前缀，后跟数组元素的数量和实际元素内容，例如：\u0026quot;*3\\r\\n$5\\r\\nHello\\r\\n$5\\r\\nWorld\\r\\n:123\\r\\n\u0026quot;表示一个包含三个元素的数组，分别是字符串\u0026quot;Hello\u0026quot;、字符串\u0026quot;World\u0026quot;和整数123。 客户端请求和服务器响应： 客户端向服务器发送命令请求时，将命令和参数按RESP协议的格式进行编码，然后通过套接字发送给服务器。 服务器接收到客户端请求后，解析RESP协议的编码格式，执行相应的命令操作，并将执行结果按RESP协议的格式进行编码，发送回客户端。 示例： 下面是一个示例，展示了RESP协议的编码和解码过程：\n客户端请求： SET mykey Hello\n编码后的请求： *3\\r\\n$3\\r\\nSET\\r\\n$5\\r\\nmykey\\r\\n$5\\r\\nHello\\r\\n\n服务器响应： +OK\n编码后的响应： \u0026quot;+OK\\r\\n\u0026quot;\n总结： RESP协议是Redis使用的通信协议，采用简单的文本格式，具有固定的数据类型和编码规则。它支持简单字符串、错误信息、整数、大块字符串和数组等数据类型。客户端和服务器通过RESP协议编码和解码数据，实现命令请求和响应的交互。RESP协议的简单性和可读性使其易于实现和调试，并在Redis中发挥着关键作用。\n四、Redis内存策略\rRedis中的内存策略主要包含下列四点：\n内存清除策略（Eviction Policy）：当Redis内存空间不足时，会根据特定的算法删除一些key来释放内存。其中，常用的算法有LRU（最近最少使用）、LFU（最少使用频率）和随机算法。\n内存淘汰策略（Expiration）：在插入或更新key的时候，可以指定key的过期时间（expire）时间。过期后，Redis会自动将key删除，释放内存。\n内存回收策略（Memory Reclamation）：在使用Redis时，可能会因为未正确释放内存而导致内存泄漏。Redis针对这种情况实现了自动内存回收机制来防止内存泄漏的问题。\n内存优化策略（Memory Optimization）：Redis提供了各种内存优化策略，例如使用压缩（压缩整数值、压缩非常短的字符串）、使用哈希对象来优化内存使用等，以最大限度地减少内存使用。Redis也使用专门的数据结构来实现某些特定的数据类型，例如基数计数器和位数组，这些也是为了优化内存使用而设计的。\n这些策略可以帮助Redis在处理大量数据时保持高效并避免内存溢出。但也需要注意，在处理大量数据时，仍然需要监控内存使用情况，并及时优化和调整内存策略。\n4.1 过期策略 - key处理\rRedis之所以性能强，最主要的原因就是基于内存存储。然而单节点的Redis其内存大小不宜过大，会影响持久化或主从同步性能。 我们可以通过修改配置文件来设置Redis的最大内存：\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653983341150.png](Java/Java Web/Redis/files/ab23fd96c2390f63b4085d7ded38e94f_MD5.png)\n当内存使用达到上限时，就无法存储更多数据了。为了解决这个问题，Redis提供了一些策略实现内存回收：内存过期策略\n可以通过expire命令给Redis的key设置TTL（存活时间）：\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653983366243.png](Java/Java Web/Redis/files/0a9c6dba68090d86857764b3df0a4500_MD5.png)\n可以发现，当key的TTL到期以后，再次访问name返回的是nil，说明这个key已经不存在了，对应的内存也得到释放。从而起到内存回收的目的。\n4.1.1 Redis是如何知道一个key是否过期呢？\rDB结构体\rRedis本身是一个典型的key-value内存存储数据库，因此所有的key、value都保存在之前学习过的Dict结构中。\nRedis判断一个key是否过期：不过是在其database结构体中，有两个Dict：一个用来记录key-value；另一个用来记录key-TTL\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653983423128.png](Java/Java Web/Redis/files/ee61108e8cdfc102732c2e0ff8dd2485_MD5.png)\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653983606531.png](Java/Java Web/Redis/files/01ac4bafcbf2f4c81087fd3286a56e8d_MD5.png)\n4.1.2 是不是TTL到期就立即删除了呢？\r惰性删除\r惰性删除：顾明思议并不是在TTL到期后就立刻删除，而是在访问一个key的时候，检查该key的存活时间，如果已经过期才执行删除。也就是在增删改查的时候才会去检查这个key去判断这个key是否有过期。\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653983652865.png](Java/Java Web/Redis/files/e7636903d7126329cb03217d1ca95e5d_MD5.png)\n周期删除\r周期删除：顾明思议是通过一个定时任务，周期性的抽样部分过期的key，然后执行删除。执行周期有两种：\n在Redis服务**初始化函数initServer()**中设置定时任务serverCron()，按照server.hz的频率来执行过期key清理，模式为SLOW 在Redis的每个事件循环前会调用beforeSleep()函数，执行过期key清理，模式为FAST SLOW模式规则：\n执行频率受server.hz影响，默认为10，即每秒执行10次，每个执行周期100ms。 执行清理耗时不超过一次执行周期的25%.默认slow模式耗时不超过25ms 逐个遍历db，逐个遍历db中的bucket，抽取20个key判断是否过期 如果没达到时间上限（25ms）并且过期key比例大于10%(就是过期的key和数据库中总的key进行对比)，再进行一次抽样，否则结束 ![image-20230601164100282](Java/Java Web/Redis/files/8b8da424420d40752442e745ed128972_MD5.png)\nFAST模式规则（过期key比例小于10%不执行 ）：\n执行频率受beforeSleep()调用频率影响，但两次FAST模式间隔不低于2ms 执行清理耗时不超过1ms 逐个遍历db，逐个遍历db中的bucket，抽取20个key判断是否过期 如果没达到时间上限（1ms）并且过期key比例大于10%，再进行一次抽样，否则结束 4.1.3 小总结\rRedisKey的TTL记录方式：\n在RedisDB中通过一个Dict记录每个Key的TTL时间 过期key的删除策略：\n惰性清理：每次查找key时判断是否过期，如果过期则删除\n如果一个key过期了, 并且没有被再次访问, 这个key就不会被清理了, 所以需要定时清理 定期清理：定期抽样部分key，判断是否过期，如果过期则删除。 定期清理的两种模式：\nSLOW模式执行频率默认为10，每次不超过25ms\nFAST模式执行频率不固定，但两次间隔不低于2ms，每次耗时不超过1ms\n4.2 Redis内存回收-内存淘汰策略\r内存淘汰：就是当Redis内存使用达到设置的上限时，主动挑选部分key删除以释放更多内存的流程。Redis会在处理客户端命令的方法processCommand()中尝试做内存淘汰\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653983978671.png](Java/Java Web/Redis/files/11d977b02ee99940d0cfc5509e1a4b49_MD5.png)\nRedis支持8种不同策略来选择要删除的key：\nnoeviction： 不淘汰任何key，但是内存满时不允许写入新数据，默认就是这种策略。 volatile-ttl： 对设置了TTL的key，比较key的剩余TTL值，TTL越小越先被淘汰 allkeys-random：对全体key ，随机进行淘汰。也就是直接从db-\u0026gt;dict中随机挑选 volatile-random：对设置了TTL的key ，随机进行淘汰。也就是从db-\u0026gt;expires中随机挑选。 allkeys-lru： 对全体key，基于LRU算法进行淘汰 volatile-lru： 对设置了TTL的key，基于LRU算法进行淘汰 allkeys-lfu： 对全体key，基于LFU算法进行淘汰 volatile-lfu： 对设置了TTL的key，基于LFI算法进行淘汰。其中比较容易混淆的有两个： LRU（Least Recently Used），最少最近使用。用当前时间减去最后一次访问时间，这个值越大则淘汰优先级越高。 LFU（Least Frequently Used），最少频率使用。会统计每个key的访问频率，值越小淘汰优先级越高。 Redis的数据都会被封装为RedisObject结构：\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653984029506.png](Java/Java Web/Redis/files/50882a69f9aaeeefa87d67aee490ab4e_MD5.png)\nLFU的访问次数之所以叫做逻辑访问次数，是因为并不是每次key被访问都计数，而是通过运算：\n生成0~1之间的随机数R 计算 (旧次数 * lfu_log_factor + 1)，记录为P 如果 R \u0026lt; P ，因为旧次数的增加, 分母变大, 整体p变小, 因此 r\u0026lt;p的概率变小, ,则计数器 + 1，且最大不超过255 访问次数会随时间衰减，距离上一次访问时间每隔 lfu_decay_time 分钟，计数器 -1 当Redis使用的内存超出maxmemory设置时，会根据指定的淘汰策略在键空间中选择要删除的键值对。在删除键值对时，Redis会先检查该键值对是否在使用中（例如，有没有客户端正在访问该键值对），然后再根据具体的淘汰策略选择一个待删除的键值对，并将其从缓存中清除。\n最后用一副图来描述当前的这个流程吧\n![https://my-notes-li.oss-cn-beijing.aliyuncs.com/li/1653984085095.png](Java/Java Web/Redis/files/c8f99b0de3e4a9be4bec0fbb14d923ba_MD5.png)\n如果把redis全部的key都拿出来进行比较在淘汰这样消耗的时间就会大大的增大所以这里就引入了一个叫eviction——pool（驱逐池）eviction_pool就是抽取的一些样本，将样本放到池子里，再去比较看看谁应该被淘汰，这里有一个maxmemory_samples默认值是5，但是策略不同淘汰的方式不同，这样实现就会比较麻烦所以这里进行了统一，就是按照key其中的某一个值的升序排列，值越大的优先淘汰\n例如LFU最少频率使用，使用的越少就应该越早被淘汰，但是是升序排列的，那么就用255-LFU计算，LFU越少255-LFU就越大越应该被淘汰\n再说说怎么优化Redis内存\n4.3 内存优化策略\rRedis中的内存优化策略可以帮助Redis在处理大量数据时最大限度地减少内存使用和提升性能。以下是常见的内存优化策略：\n压缩整数值：Redis会根据使用的整数值类型（int16、int32）的不同，动态地压缩整数值的内存占用大小，将8字节的long类型压缩到int类型的4字节，将4字节的int类型压缩到short类型的2字节，以此来减少内存占用。（整数值的大小可以影响内存占用，而Redis可以根据数字类型的不同，动态地压缩整数值的内存占用大小） 压缩短字符串：在Redis内部，长度小于一定值的字符串被称为小字符串，并且对小字符串进行压缩。默认情况下，当字符串的长度小于44个字节时，Redis会动态地把它压缩到一个长度更短的编码中，减少内存占用。（当字符串的长度小于一定值时，Redis会自动把它压缩到一个长度更短的编码中） 使用哈希对象：当需要存储一批内存占用相同的键值对对象时，可以使用哈希对象，把所有的对象都存储在一个哈希表里，以此优化内存使用。（当需要存储一批内存占用相同的键值对对象时，可以使用Redis中的哈希对象，把所有的对象都存储在一个哈希表里，以此优化内存使用。） 使用专用数据结构：Redis使用专用的数据结构来优化某些特定类型的数据，例如在位图中，每个位使用1或0来表示某个事件发生或未发生，以此来大大减少内存占用。Redis还在内存里实现了基数估算器，这个数据结构可以帮助快速估算一批数据的唯一值的数量。 删除大列表的尾部：当某个列表的长度很大，比如数百万条甚至数千万条记录时，删除列表头部的记录（从左边开始）可能会变得很慢。此时，可以采用删除列表尾部（从右边开始）的方式，这样就可以在常数时间内完成删除操作。 接下来我将举几个Redis中的内存优化策略的例子：\n压缩整数值： 假设您在Redis中存储了一个计数器，每次增加1。如果您的计数器始终保持在0到255之间，Redis会使用8位编码来存储该整数值，而不是使用更大的数据类型。这样，Redis可以节省内存并提高存储效率。\n压缩短字符串： 假设您有一个Redis键存储了一组国家名称的字符串，例如\u0026quot;China\u0026quot;、“USA\u0026quot;和\u0026quot;India”。由于这些字符串很短，Redis可以使用intset数据结构来存储它们，而不是使用较大的数据结构。这样可以显著减少内存使用。\n使用哈希对象： 假设您在Redis中存储了一个用户对象，其中包含用户名、电子邮件和年龄等信息。当对象较小且字段数量有限时，Redis可能会使用ziplist来存储该哈希对象，而不是使用更大的散列表。这样可以节省内存并提高性能。\n使用专用数据结构： 假设您需要在Redis中存储一组用户ID的布隆过滤器，以快速判断某个用户ID是否存在。布隆过滤器是一种空间效率很高的数据结构，它可以用较少的内存占用来判断元素的存在性。通过使用布隆过滤器，您可以节省大量内存而不必存储所有用户ID的实际值。\n删除大列表的尾部： 假设您有一个Redis列表用于存储日志消息，每天会不断向该列表中添加新的消息。为了避免内存占用过高，您可以定期使用LTRIM命令来删除列表的尾部元素，只保留最近的一部分日志。这样可以限制列表的长度，减少内存使用，并确保Redis性能良好。\n压缩整数值：Redis会根据使用的整数值类型（int16、int32）的不同，动态地压缩整数值的内存占用大小，将8字节的long类型压缩到int类型的4字节，将4字节的int类型压缩到short类型的2字节，以此来减少内存占用。（整数值的大小可以影响内存占用，而Redis可以根据数字类型的不同，动态地压缩整数值的内存占用大小）\n压缩短字符串：在Redis内部，长度小于一定值的字符串被称为小字符串，并且对小字符串进行压缩。默认情况下，当字符串的长度小于44个字节时，Redis会动态地把它压缩到一个长度更短的编码中，减少内存占用。（当字符串的长度小于一定值时，Redis会自动把它压缩到一个长度更短的编码中）\n使用哈希对象：当需要存储一批内存占用相同的键值对对象时，可以使用哈希对象，把所有的对象都存储在一个哈希表里，以此优化内存使用。（当需要存储一批内存占用相同的键值对对象时，可以使用Redis中的哈希对象，把所有的对象都存储在一个哈希表里，以此优化内存使用。）\n使用专用数据结构：Redis使用专用的数据结构来优化某些特定类型的数据，例如在位图中，每个位使用1或0来表示某个事件发生或未发生，以此来大大减少内存占用。Redis还在内存里实现了基数估算器，这个数据结构可以帮助快速估算一批数据的唯一值的数量。\n删除大列表的尾部：当某个列表的长度很大，比如数百万条甚至数千万条记录时，删除列表头部的记录（从左边开始）可能会变得很慢。此时，可以采用删除列表尾部（从右边开始）的方式，这样就可以在常数时间内完成删除操作。\n接下来我将举几个Redis中的内存优化策略的例子：\n压缩整数值： 假设您在Redis中存储了一个计数器，每次增加1。如果您的计数器始终保持在0到255之间，Redis会使用8位编码来存储该整数值，而不是使用更大的数据类型。这样，Redis可以节省内存并提高存储效率。 压缩短字符串： 假设您有一个Redis键存储了一组国家名称的字符串，例如\u0026quot;China\u0026quot;、“USA\u0026quot;和\u0026quot;India”。由于这些字符串很短，Redis可以使用intset数据结构来存储它们，而不是使用较大的数据结构。这样可以显著减少内存使用。 使用哈希对象： 假设您在Redis中存储了一个用户对象，其中包含用户名、电子邮件和年龄等信息。当对象较小且字段数量有限时，Redis可能会使用ziplist来存储该哈希对象，而不是使用更大的散列表。这样可以节省内存并提高性能。 使用专用数据结构： 假设您需要在Redis中存储一组用户ID的布隆过滤器，以快速判断某个用户ID是否存在。布隆过滤器是一种空间效率很高的数据结构，它可以用较少的内存占用来判断元素的存在性。通过使用布隆过滤器，您可以节省大量内存而不必存储所有用户ID的实际值。 删除大列表的尾部： 假设您有一个Redis列表用于存储日志消息，每天会不断向该列表中添加新的消息。为了避免内存占用过高，您可以定期使用LTRIM命令来删除列表的尾部元素，只保留最近的一部分日志。这样可以限制列表的长度，减少内存使用，并确保Redis性能良好。 本文转自 https://blog.csdn.net/qq_56098191/article/details/130992269?ops_request_misc=\u0026request_id=\u0026biz_id=102\u0026utm_term=redis%E5%8E%9F%E7%90%86\u0026utm_medium=distribute.pc_search_result.none-task-blog-2~all~sobaiduweb~default-3-130992269.142^v102^pc_search_result_base9\u0026spm=1018.2226.3001.4187，如有侵权，请联系删除。\n","date":"2026-04-11T00:00:00Z","permalink":"/p/redis-%E5%8E%9F%E7%90%86%E7%AF%87-%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E4%B8%8E%E5%BA%95%E5%B1%82%E5%AE%9E%E7%8E%B0/","title":"Redis 原理篇 - 数据结构与底层实现"},{"content":"Shell 脚本基础\rfrp 安装脚本\r检查依赖\r脚本启动前需要确认 wget 和 unzip 已安装：\n#!/bin/bash #! 开头指定解释器。 if ! command -v wget \u0026amp;\u0026gt;/dev/null; then echo \u0026#34;wget 未安装\u0026#34; exit 1 fi if ! command -v unzip \u0026amp;\u0026gt;/dev/null; then echo \u0026#34;unzip 未安装\u0026#34; exit 1 fi command -v \u0026lt;tool\u0026gt; 的返回码为 0 表示命令可用，可以用 echo $? 查看上一次命令的退出码。\n获取最新版本\r通过 GitHub API 提取最新的 tag_name：\nFRP_VERSION=$(curl -s https://api.github.com/repos/fatedier/frp/releases/latest | grep -oP \u0026#39;\u0026#34;tag_name\u0026#34;: \u0026#34;\\\\K(.*)(?=\u0026#34;)\u0026#39;) 上面的正则表达式提取 \u0026quot;tag_name\u0026quot; 后面的版本号。\necho \u0026#34;下载 frp 版本 $FRP_VERSION...\u0026#34; wget https://github.com/fatedier/frp/releases/download/${FRP_VERSION}/frp_${FRP_VERSION}_linux_amd64.tar.gz 解压安装\r解压并移动到 /usr/local/bin：\ntar -xvzf frp_${FRP_VERSION}_linux_amd64.tar.gz mv frp_${FRP_VERSION}_linux_amd64 /usr/local/bin/ rm -f frp_${FRP_VERSION}_linux_amd64* 配置\r配置文件可以放在 /etc/frp/ 下，利用 cat \u0026lt;\u0026lt;EOF 简洁写入：\nmkdir -p /etc/frp cat \u0026lt;\u0026lt;EOF \u0026gt; /etc/frp/frp.ini [common] server_addr = 你的服务器地址 server_port = 7000 [ssh] type = tcp local_ip = 127.0.0.1 local_port = 22 remote_port = 6000 EOF 此处 EOF 只是界定字符串的符号，只要前后一致即可。\n服务\r使用 tee 将 service 文件写入 /etc/systemd/system/frpc.service，并立即启用：\necho \u0026#34;创建 systemd 服务文件...\u0026#34; cat \u0026lt;\u0026lt;EOF | sudo tee /etc/systemd/system/frpc.service \u0026gt; /dev/null [Unit] Description=frpc service After=network.target [Service] Type=simple User=root ExecStart=/usr/local/bin/frpc -c /etc/frp/frpc.ini Restart=on-failure [Install] WantedBy=multi-user.target EOF sudo systemctl daemon-reload sudo systemctl start frp sudo systemctl enable frp ","date":"2026-04-11T00:00:00Z","permalink":"/p/shell-%E8%84%9A%E6%9C%AC%E5%9F%BA%E7%A1%80/","title":"Shell 脚本基础"},{"content":"Spring Boot AOP 切面编程\r概述\r所有函数都要加代码, 繁琐; demo\r概念\r执行流程\r使用代理对象 进行注入; 进阶\r切点引用 pointCut\rpt 函数改为 public , 其他aspect 类中也可以引用 通知顺序\r切入点表达式\rexecution\r只写方法名, 会匹配所有同名方法; ;因此不建议省略包名类名; annotation\r连接点\r案例 记录日志\r获取token\r获取token直接从请求header中获取,不要从后端其他类中获取 package org.hzl.aop; import com.alibaba.fastjson.JSONObject; import io.jsonwebtoken.Claims; import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.hzl.mapper.OperateLogMapper; import org.hzl.pojo.OperateLog; import org.hzl.utils.JwtUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import java.time.LocalDateTime; import java.util.Arrays; @Slf4j @Component @Aspect public class LogAspect { @Autowired private OperateLogMapper operateLogMapper; @Autowired private HttpServletRequest request; @Around(\u0026#34;@annotation(org.hzl.anno.Log)\u0026#34;) public Object recordLog(ProceedingJoinPoint joinPoint) throws Throwable { // 获取emp 姓名 , 从 request 中jwt 令牌中获取 String token = request.getHeader(\u0026#34;token\u0026#34;); Claims claims = JwtUtils.parseToken(token); Integer operateUser = (Integer) claims.get(\u0026#34;id\u0026#34;); // 操作时间 LocalDateTime operateTime = LocalDateTime.now(); // class name String className = joinPoint.getTarget().getClass().getName(); // 方法名 String methodName = joinPoint.getSignature().getName(); // 参数 String methodParams = Arrays.toString(joinPoint.getArgs()); // 返回值 将返回值 转陈 json 类型字符串 long begin = System.currentTimeMillis(); Object result = joinPoint.proceed(); long end = System.currentTimeMillis(); String resultValue = JSONObject.toJSONString(result); // 方法 耗时 long costTime = end - begin; OperateLog operateLog = new OperateLog(null,operateUser,operateTime,className,methodName,methodParams,resultValue,costTime); operateLogMapper.insert(operateLog); log.info(\u0026#34;AOP中操作日志:{}\u0026#34;,operateLog); return result; } } @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface Log { } Log 注解 aop 根据 @Log 注解 进行包裹. 在controller中的 增 删 改 方法前使用@Log 注解标记 @Log @DeleteMapping(\u0026#34;/{id}\u0026#34;) public Result delete(@PathVariable Integer id){ deptService.delete(id); log.info(\u0026#34;根据日志输出{}\u0026#34;,id); return Result.success(); } @Log @PostMapping public Result add(@RequestBody Dept dept){ log.info(\u0026#34;新增部门{}\u0026#34;,dept); deptService.add(dept); return Result.success(); } 获取注解中的参数\r指定注解可以接受value 参数, 类型是某个枚举类型\n@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) // @interface 表示注解 public @interface AutoFill { //UPDATE INSERT // 注解中会出现 @AutoFill(value = ) 等于的值为 OperationType中指定的 OperationType value(); } 获取参数 在 @Before(\u0026quot;@annotation(autoFill)\u0026quot; ) 中填入注解形参的名字, 函数第二个参数设置log注解即可;\n@Slf4j @Component @Aspect public class AutoFillAspect { /** * 切入点 */ @Pointcut(\u0026#34;@annotation(com.sky.annotation.AutoFill)\u0026#34;) public void pt(){}; @Before(\u0026#34;@annotation(autoFill)\u0026#34; ) public void AutoFill(JoinPoint joinPoint,AutoFill autoFill) throws NoSuchMethodException, InvocationTargetException, IllegalAccessException { log.info(\u0026#34;开始执行公共字段填充\u0026#34;); //拦截mapper方法 update操作 只用 updateTime updateUser 赋值即可 // 获取方法的参数实体对象 OperationType operationType = autoFill.value(); log.info(\u0026#34;获取到传入value:{}\u0026#34;,operationType); ","date":"2026-04-11T00:00:00Z","permalink":"/p/spring-boot-aop-%E5%88%87%E9%9D%A2%E7%BC%96%E7%A8%8B/","title":"Spring Boot AOP 切面编程"},{"content":"Spring Boot Starter 自动配置原理\r配置\r优先级 properties \u0026gt; yml \u0026gt; yaml 命令行优先, java后 target目录下 Bean\r获取bean\r获取 ioc 容器: 自动注入 ApplicationContext 对象 bean 作用域\r@Lazy 标记bean 第一次使用时再初始化 @Scope(\u0026ldquo;prototype\u0026rdquo;) 第三方bean\r@Configuration\r标记配置类, 配置类中会声明 @Bean 对象, 加载到容器内;\nSpringBoot 原理\r基于 springframework(;依赖配置繁琐) 起步依赖, 自动配置 起步依赖\rmaven 的依赖传递\n0000.starter 就是起步依赖 自动配置\r声明的Bean 类自动存入ioc 容器内; 自动配置方法\r导入普通类\r导入 配置类\r配置类\n导入\n导入 ImportSelector 实现类\rselector 实现类\nImportSelector 实现类 重写方法 返回要加载的全类名数组即可\n这里是 config 配置类\n导入\nEnable**** 注解\r自定义注解 selector HeaderConfig 添加注解即可 源码分析\rEnableAutoConfiguration 注解封装了, import(selector) selector 就要返回要加载的类, 这里返回的都是自动配置类, 里面声明了@Bean 参考上面的 Enable**** conditionalOnMissingBean\r存在 指定类, 才加载 没有这个bean 才加载 判断配置文件中的key 和value, 有才加载 总结\rstarter 解读\r以 mybatis 为例\nautoconfig 文件目录\nMybatisAutoConfiguration\nstarter 中引入依赖 mybatis-spring-boot-autoconfigure\nmybatis-spring-boot-autoconfigure 包中 spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 里面声明了自动配置类(自动配置类中声明了@Bean)\nspringboot -\u0026gt; @SpringBootApplication-\u0026gt;@EnableAutoConfiguration-\u0026gt;@Import({AutoConfigurationImportSelector.class})-\u0026gt;selectImports()得到 spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 配置类后自动加载\n@EnableConfigurationProperties\r在自动配置时, bean 对象的初始化参数可能是其他类(被@ConfigurationProperties注解标记的类) 这时需要在自动配置类上添加 @EnableConfigurationProperties({MybatisProperties.class}) 将 MybatisProperties 自动注入到容器中 取值直接用Bean参数接受即可 自定义starter\r实现\rstarter 中添加依赖 \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.aliyun.oss\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;aliyun-springboot-autoconfigure\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;0.0.1-SNAPSHOT\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; autoconfig 中添加aliyun相关依赖 https://www.bilibili.com/video/BV1m84y1w7Tb?spm_id_from=333.788.player.switch\u0026vd_source=36d687ece6545eefcda2731026b8d90d\u0026p=193\naliyunproperties 是自动获取参数的, 需要在自动配置类上天剑enable注解才不报错(@configurationProperties报错) autoconfiguration 中 aliyunossutils 中是使用aliyunossproperties 连接oss 上传文件的逻辑 苍穹外卖\r[9. 文件上传](Java/Java Web/苍穹外卖/苍穹外卖文件上传.md#文件上传)\n如果设置成 starter\n新建包 starter 引用 autoconfiguration 在 autoconfiguration 中新建 java 文件 AliOssProperties, AliOssutils AliOssProperties 负责将配置中的字段加载到容器中, 但是 AliOssProperties 类不加 @Component 注解(即使加了也不会扫描到), 因此这个类不会自动加载到容器中 AliOssUtils 通过 AliOssProperties 的参数创建连接上传文件 AliOssProperties 没有在容器中, 因此需要设置自动配置类(简单讲它负责从容器中拿到 properties 并实例化 Utils 并将 Utils 放到容器中, 在使用时只需注入即可 ) AliOssAutoConfiguration 负责从容器中获取 AliOssProperties 并实例化 AliOssUtils 放到容器中 ","date":"2026-04-11T00:00:00Z","permalink":"/p/spring-boot-starter-%E8%87%AA%E5%8A%A8%E9%85%8D%E7%BD%AE%E5%8E%9F%E7%90%86/","title":"Spring Boot Starter 自动配置原理"},{"content":"Spring Boot 多模块项目\r说明\r// 项目地址\rhttps://github.com/yizhiwazi/springboot-socks/tree/master/springboot-integration 项目结构\rpom文件\r父工程(聚合)\r只有pom文件 父模块中有 mm-web mm-service mm-repo mm-entity 因为这些子模块还要相互引用, 直接从父模块中引用即可; 父模块不用 build 标签, 因为最后运行的不是父模块, 付模块只是依赖管理; \u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;project xmlns=\u0026#34;http://maven.apache.org/POM/4.0.0\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\u0026#34;\u0026gt; \u0026lt;!-- 基本信息 --\u0026gt; \u0026lt;description\u0026gt;SpringBoot 多模块构建示例\u0026lt;/description\u0026gt; \u0026lt;modelVersion\u0026gt;4.0.0\u0026lt;/modelVersion\u0026gt; \u0026lt;name\u0026gt;springboot-integration\u0026lt;/name\u0026gt; \u0026lt;packaging\u0026gt;pom\u0026lt;/packaging\u0026gt; \u0026lt;!-- 项目说明：这里作为聚合工程的父工程 --\u0026gt; \u0026lt;groupId\u0026gt;com.hehe\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;springboot-integration\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0.0.RELEASE\u0026lt;/version\u0026gt; \u0026lt;!-- 继承说明：这里继承SpringBoot提供的父工程 --\u0026gt; \u0026lt;parent\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-parent\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.5.7.RELEASE\u0026lt;/version\u0026gt; \u0026lt;relativePath/\u0026gt; \u0026lt;/parent\u0026gt; \u0026lt;!-- 模块说明：这里声明多个子模块 --\u0026gt; \u0026lt;modules\u0026gt; \u0026lt;module\u0026gt;mm-web\u0026lt;/module\u0026gt; \u0026lt;module\u0026gt;mm-service\u0026lt;/module\u0026gt; \u0026lt;module\u0026gt;mm-repo\u0026lt;/module\u0026gt; \u0026lt;module\u0026gt;mm-entity\u0026lt;/module\u0026gt; \u0026lt;/modules\u0026gt; \u0026lt;!-- 版本说明：这里统一管理依赖的版本号 --\u0026gt; \u0026lt;dependencyManagement\u0026gt; \u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.hehe\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mm-web\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;0.0.1-SNAPSHOT\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.hehe\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mm-service\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;0.0.1-SNAPSHOT\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.hehe\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mm-repo\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;0.0.1-SNAPSHOT\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.hehe\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mm-entity\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;0.0.1-SNAPSHOT\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;mysql\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mysql-connector-java\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;5.1.42\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; \u0026lt;/dependencyManagement\u0026gt; \u0026lt;/project\u0026gt; 子模块\r以web为例, 这里需要引用其他子模块, 不用写版本号直接从父模块dependencyManagement获取; web模块是最后运行的包, 最后再写 build; \u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;project xmlns=\u0026#34;http://maven.apache.org/POM/4.0.0\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\u0026#34;\u0026gt; \u0026lt;modelVersion\u0026gt;4.0.0\u0026lt;/modelVersion\u0026gt; \u0026lt;!-- 基本信息 --\u0026gt; \u0026lt;groupId\u0026gt;com.hehe\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mm-web\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;0.0.1-SNAPSHOT\u0026lt;/version\u0026gt; \u0026lt;packaging\u0026gt;jar\u0026lt;/packaging\u0026gt; \u0026lt;name\u0026gt;mm-web\u0026lt;/name\u0026gt; \u0026lt;!-- 继承本项目的父工程 --\u0026gt; \u0026lt;parent\u0026gt; \u0026lt;groupId\u0026gt;com.hehe\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;springboot-integration\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0.0.RELEASE\u0026lt;/version\u0026gt; \u0026lt;/parent\u0026gt; \u0026lt;!-- Web模块相关依赖 --\u0026gt; \u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.hehe\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mm-service\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.hehe\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mm-entity\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-web\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;mysql\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mysql-connector-java\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-test\u0026lt;/artifactId\u0026gt; \u0026lt;scope\u0026gt;test\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; \u0026lt;/project\u0026gt; 启动器\rspring-boot-starter 没选web spring-boot-starter-web 选了web框架 以上两个都是启动器, web中使用 spring-boot-starter-web\nbuild\r多模块项目仅仅需要在启动类所在的模块添加打包插件即可！！不要在父类添加打包插件，因为那样会导致全部子模块都使用spring-boot-maven-plugin的方式来打包（例如BOOT-INF/com/hehe/xx），而mm-web模块引入mm-xx 的jar 需要的是裸露的类文件，即目录格式为（/com/hehe/xx）。 本案例的启动模块是 mm-web ， 只需在它的pom.xml 添加打包插件（spring-boot-maven-plugin）:\n\u0026lt;!--多模块打包：只需在启动类所在模块的POM文件：指定打包插件 --\u0026gt; \u0026lt;build\u0026gt; \u0026lt;plugins\u0026gt; \u0026lt;plugin\u0026gt; \u0026lt;!--该插件主要用途：构建可执行的JAR --\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-maven-plugin\u0026lt;/artifactId\u0026gt; \u0026lt;/plugin\u0026gt; \u0026lt;/plugins\u0026gt; \u0026lt;/build\u0026gt; maven 插件 clean , package 在targetmulu生成 jar包; java -jar mm-web-0.0.1-SNAPSHOT.jar 注解在的包\rcomponent\r","date":"2026-04-11T00:00:00Z","permalink":"/p/spring-boot-%E5%A4%9A%E6%A8%A1%E5%9D%97%E9%A1%B9%E7%9B%AE/","title":"Spring Boot 多模块项目"},{"content":"Spring Boot 基础\r请求\r注解\rcontroller 上面 @RestController 注解 controller 类中 函数 上面 @RequestMapping(\u0026quot;/path\u0026quot;) 不同请求方法注解\r@RequestMapping(value = \u0026#34;/depts\u0026#34;, method = RequestMethod.GET) 或者 @GetMapping(\u0026#34;/depts\u0026#34;) GetMapping、PutMapping、DeleteMapping、PatchMapping PostMapping 接受简单参数\rpackage org.hzl.controller; import jakarta.servlet.http.HttpServletRequest; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; //要加 RestController 才行 @RestController public class Controller { @RequestMapping(\u0026#34;/hello\u0026#34;) public String hello() { return \u0026#34;hello world\u0026#34;; } // @RequestMapping(\u0026#34;/simpleParam\u0026#34;) // public String simpleParam(HttpServletRequest request){ // String name = request.getParameter(\u0026#34;name\u0026#34;); // String ageStr = request.getParameter(\u0026#34;age\u0026#34;); // int age = Integer.parseInt(ageStr); // System.out.println(name+\u0026#34;:\u0026#34;+age); // return \u0026#34;收到\u0026#34;; // } //springboot 方式: 形参名字和参数保持一致, 自动类型转换. //http://localhost:8080/simpleParam?name=Tom\u0026amp;age=19 参数key是name, 下面的形参也得是name才行. @RequestMapping(\u0026#34;/simpleParam\u0026#34;) //如果不想用name接受, 使用RequestParameter注解 public String simpleParam(String name, Integer age) { System.out.println(name+\u0026#34;:\u0026#34;+age); return \u0026#34;收到\u0026#34;; } // 将原始的 image 数据用 age 重命名接受 public String simpleParam(String name, @RequestParam(\u0026#34;image\u0026#34;) Integer age) { System.out.println(name+\u0026#34;:\u0026#34;+age); return \u0026#34;收到\u0026#34;; } } 接受实体参数\r// 如果url携带参数是name1, 那么这里的user.name会是null @RequestMapping(\u0026#34;/simplePojo\u0026#34;) public String simplePojo(User user){ System.out.println(user); return \u0026#34;收到User\u0026#34;; } @RequestMapping(\u0026#34;/complexPojo\u0026#34;) public String complexPojo(User user){ System.out.println(user); return \u0026#34;收到User\u0026#34;; } 数组集合\r@RequestMapping(\u0026#34;/arrayParam\u0026#34;) public String arrayParam(String[] hobby){ System.out.println(Arrays.toString(hobby)); return \u0026#34;收到Hobby\u0026#34;; } @RequestMapping(\u0026#34;/listParam\u0026#34;) public String listParam(@RequestParam List\u0026lt;String\u0026gt; hobby){ System.out.println(hobby); return \u0026#34;收到Hobby\u0026#34;; } ids = 1, 2,3, spring会自动处理, 要加 @RequestParam 在 deleteMapping 中不需要加路径 @DeleteMapping public Result delete(@RequestParam List\u0026lt;Long\u0026gt; ids){ return Result.success(); } 日期\r@RequestMapping(\u0026#34;/dateParam\u0026#34;) public String dateParam(@DateTimeFormat(pattern = \u0026#34;yyyy-MM-dd HH:mm:ss\u0026#34;) LocalDateTime updateTime){ System.out.println(updateTime); return \u0026#34;收到日期\u0026#34;; } json\r@RequestMapping(\u0026#34;/jsonParam\u0026#34;) public String jsonParam(@RequestBody User user){ System.out.println(user); return \u0026#34;收到json\u0026#34;; } 接受path参数\r@RequestMapping(\u0026#34;/path/{id}\u0026#34;) public String pathParam(@PathVariable Integer id){ System.out.println(id); return \u0026#34;收到path\u0026#34;; } @RequestMapping(\u0026#34;/path/{id}/{name}\u0026#34;) public String pathParam2(@PathVariable Integer id, @PathVariable String name){ System.out.println(id+name); return \u0026#34;收到多个path\u0026#34;; } 响应\r统一结构Result\r为了使接口的返回值有统一的规范, 使用Result作为接口的返回值.\n// 由于返回值的多样性, 设置Result类统一规范, 所有接口都返回Result对象. public class Result { private Integer code; private String msg; private Object data; public Result(Integer code, String msg, Object data) { this.code = code; this.msg = msg; this.data = data; } public static Result success(){ return new Result(1, \u0026#34;success\u0026#34;, null); } public static Result success(Object data){ return new Result(1, \u0026#34;success\u0026#34;, data); } public static Result error(String msg){ return new Result(0, msg, null); } @RequestMapping(\u0026#34;/getAddress\u0026#34;) public Result getAddress() { return Result.success(new Address(\u0026#34;hebei\u0026#34;,\u0026#34;shijiazhuang\u0026#34;)); } @RequestMapping(\u0026#34;/getList\u0026#34;) public Result getList() { ArrayList\u0026lt;String\u0026gt; arr = new ArrayList\u0026lt;\u0026gt;(); Collections.addAll(arr,\u0026#34;han\u0026#34;,\u0026#34;liu\u0026#34;,\u0026#34;zhu\u0026#34;,\u0026#34;fang\u0026#34;,\u0026#34;LOL\u0026#34;); return Result.success(arr); } @RequestMapping(\u0026#34;/getError\u0026#34;) public Result error() { return Result.error(\u0026#34;not found\u0026#34;); } 返回文件\r三层架构\rEmpDao 是接口, EmpDaoA是一个实现类(当前是从xml文件中读取数据, 如果改为从数据库中读取数据, 只需实现另一个类 EmpDaoB即可.) EmpService 是接口, Service实现数据转换, 当前是 1表示男性, 0表示女性, 如果改为 其他规则, 只需实现另一个类 EmpServiceB即可. 接口使得程序扩展和修改维护变得容易.\nEmpDao empDao = new EmpDaoA() 接口的多态特性.\n这里的Service 还是用到了 EmpDaoA, 存在耦合.\n解耦合\rComponent 可以换成 Service(对应上面的Service层, 处理逻辑, 数据转换.) repository Component等注解要被扫描才能生效 ioc 注解 Service 实现类有多个\r当 Service(或Component) 修饰的EmpService接口的子类有多个时, 报错 , 因为 Autowired 是按照类型生效的 , 两个会冲突. @Primary 加在@Service 上面, 指定默认那个生效. @Qualifier 在@Autowried下面 @Qualifier(\u0026quot;empServiceA\u0026quot;) 指定 A生效 @Autowried 换成 @Resource(name = \u0026ldquo;empServiceA\u0026rdquo;) 每个实体对应一个 Service, 和;一个实现类, 每个实体对应一个 controller\n常见操作\r获取Resource资源\rInputStream excelStream = this.getClass().getClassLoader().getResourceAsStream(\u0026#34;template/运营数据报表模板\u0026#34;); ","date":"2026-04-11T00:00:00Z","permalink":"/p/spring-boot-%E5%9F%BA%E7%A1%80/","title":"Spring Boot 基础"},{"content":"SpringBoot\r文档更新日志\r版本 更新日期 操作 描述 v1.0 2021/11/14 A 基础篇 v1.0.1 2021/11/30 U 更新基础篇错别字若干，不涉及内容变更 v2.0 2021/12/01 A 运维实用篇 V3.0 2022/2/21 A 开发实用篇 V4.0 2022/3/29 A 原理篇 前言\r​\t很荣幸有机会能以这样的形式和互联网上的各位小伙伴一起学习交流技术课程，这次给大家带来的是Spring家族中比较重要的一门技术课程——SpringBoot。一句话介绍这个技术，应该怎么说呢？现在如果开发Spring程序不用SpringBoot那就是给自己过不去，SpringBoot为我们开发Spring程序提供了太多的帮助，在此借这个机会给大家分享这门课程，希望各位小伙伴学有所得，学有所用，学有所成。\n​\t正如上面提到的，这门技术是用来加速开发Spring程序的，因此学习这门技术是有一定的门槛的。你可以理解为你现在是一门传统的手工艺人，现在工艺升级，可以加速你的生产制作过程，但是前提是你要会原始工艺，然后才能学习新的工艺。嗯，怎么说呢？有一定的门槛，至少Spring怎么回事，与Spring配合在一起工作的一堆技术又是怎么回事，这些搞明白才能来看这个技术，不然就只能学个皮毛，或者学着学着就开始因为其他技术不太过关，然后就学不下去了，然后，就没有然后了，果断弃坑了。不管怎么说，既来之则安之，加油学习吧，投资自己肯定是没毛病的。\n课程内容说明\r​\tSpringBoot这门技术课程所包含的技术点其实并不是很多，但是围绕着SpringBoot的周边知识，也就是SpringBoot整合其他技术，这样的知识量很大，例如SpringBoot整合MyBatis等等。因此为了能够将本课程制作的能够适应于各个层面的学习者进行学习，本套课程会针对小白，初学者，开发者三种不同的人群来设计全套课程。具体这三种人群如何划分，就按照我的描述形式来分吧，各位小伙伴可以对号入座，每种人群看课程的起始位置略有差别。\n学习者 归类方式 小白 完全没有用过SpringBoot技术 初学者 能使用SpringBoot技术完成基础的SSM整合 开发者 能使用SpringBoot技术实现常见的技术整合工作 ​\t简单说就是你能用SpringBoot做多少东西，一点不会就是小白，会一点就是初学者，大部分都会就是开发者。其实这个划分也不用过于纠结，这个划分仅仅是为了帮助你对本技术课程所包含的阶段模块划分做一个清晰认知，因为本课程中会将SpringBoot技术划分成4个单元，每个单元是针对不同的学习者准备的。\n学习者 课程单元 小白 基础篇 初学者 应用篇（ 运维实用篇 \u0026amp; 开发实用篇 ） 开发者 原理篇 ​\t看完这个划分你就应该有这么个概念，我没有用过SpringBoot技术，所以从基础篇开始学习；或者我会一点SpringBoot技术，那我从实用篇开始学就好了，就是这个意思。\n​\t每个课程单元内容设置不同，目标也不一样，作为学习者如果想达成最佳的学习效果，最好明确自己的学习目标再进行学习，这样目标明确，学习的时候能够更轻松，你就不会在学习的时候纠结如下的问题了。比如学着基础篇在那想，这个东西是个什么原理啊？这个东西是这么用的，那个东西该怎么用啊？因为原理性的内容统一放置到了原理篇讲解了，应用相关的内容统一放到应用篇里面讲解，你在基础篇阶段纠结也没有用，这一部分不讲这些知识，在基础篇先把SpringBoot的基础使用掌握完再说后面的知识吧。\n​\t此外还有一点需要说明的是，目前SpringBoot技术发展速度很快，更新速度也很快，因此后续还会对本套课程进行持续更新，特此在三个课程单元的基础上追加一个番外篇。番外篇的设置为了解决如下问题：\n持续更新SpringBoot后续发展出现的新技术 讲解部分知识点规模较大的支线知识（例如WebFlux） 扩展非实用性知识，扩展学习者视野 ​\t每一个课程单元的学习目标如下，请各位查收，在学习的过程中可以阶段性的给自己提个问题，下面列出来的这些学习目标你是否达成了，可以检验你的学习成果。\n课程单元 学习目标 基础篇 能够创建SpringBoot工程基于SpringBoot实现ssm/ssmp整合 应用篇 能够掌握SpringBoot程序多环境开发能够基于Linux系统发布SpringBoot工程能够解决线上灵活配置SpringBoot工程的需求能够基于SpringBoot整合任意第三方技术 原理篇 掌握SpringBoot内部工作流程理解SpringBoot整合第三方技术的原理实现自定义开发整合第三方技术的组件 番外篇 掌握SpringBoot整合非常见的第三方技术掌握相同领域的更多的解决方案，并提升同领域方案设计能力 ​\t整体课程包含的内容就是这些啦，要想完成前面这些内容的学习，顺利的达成学习目标，有些东西还是要提前和大家说清楚的。SpringBoot课程不像是Java基础，不管你有没有基础，都可以听一听，这个课程还真不行，需要一定的前置知识。下面给大家列表一些前置知识，如果还有不太会的，需要想办法快速补救一下。\n课程前置知识说明\r课程单元 前置知识 要求 基础篇 Java基础语法 面向对象，封装，继承，多态，类与接口，集合，IO，网络编程等 基础篇 Spring与SpringMVC 知道Spring是用来管理bean，能够基于Restful实现页面请求交互功能 基础篇 Mybatis与Mybatis-Plus 基于Mybatis和MybatisPlus能够开发出包含基础CRUD功能的标准Dao模块 基础篇 数据库MySQL 能够读懂基础CRUD功能的SQL语句 基础篇 服务器 知道服务器与web工程的关系，熟悉web服务器的基础配置 基础篇 maven 知道maven的依赖关系，知道什么是依赖范围，依赖传递，排除依赖，可选依赖，继承 基础篇 web技术（含vue，ElementUI) 知道vue如何发送ajax请求，如何获取响应数据，如何进行数据模型双向绑定 应用篇 Linux（CentOS 7） 熟悉常用的Linux基础指令，熟悉Linux系统目录结构 应用篇 实用开发技术 缓存：Redis、MongoDB、……消息中间件:RocketMq、RabbitMq、…… 原理篇 Spring 了解Spring加载bean的各种方式知道Spring容器底层工作原理，能够阅读简单的Spring底层源码 ​\t看着略微有点多，其实还好吧，如果个别技术真的不会，在学习课程的时候多用心听就好，基础篇是可以跟着学下来了，后面的实用篇和原理篇就比较难了。比如我要在Linux系统下操作，命令我就直接使用了，然后你看不懂可能学习起来就比较心累了。\n​\t课程安排就说到这里了，下面进入到SpringBoot基础篇的学习\nSpringBoot基础篇\r​\t在基础篇中，我给学习者的定位是先上手，能够使用SpringBoot搭建基于SpringBoot的web项目开发，所以内容设置较少，主要包含如下内容：\nSpringBoot快速入门 SpringBoot基础配置 基于SpringBoot整合SSMP JC-1.快速上手SpringBoot\r​\t学习任意一项技术，首先要知道这个技术的作用是什么，不然学完以后，你都不知道什么时候使用这个技术，也就是技术对应的应用场景。SpringBoot技术由Pivotal团队研发制作，功能的话简单概括就是加速Spring程序的开发，这个加速要从如下两个方面来说\nSpring程序初始搭建过程 Spring程序的开发过程 ​\t通过上面两个方面的定位，我们可以产生两个模糊的概念：\nSpringBoot开发团队认为原始的Spring程序初始搭建的时候可能有些繁琐，这个过程是可以简化的，那原始的Spring程序初始搭建过程都包含哪些东西了呢？为什么觉得繁琐呢？最基本的Spring程序至少有一个配置文件或配置类，用来描述Spring的配置信息，莫非这个文件都可以不写？此外现在企业级开发使用Spring大部分情况下是做web开发，如果做web开发的话，还要在加载web环境时加载时加载指定的spring配置，这都是最基本的需求了，不写的话怎么知道加载哪个配置文件/配置类呢？那换了SpringBoot技术以后呢，这些还要写吗？谜底稍后揭晓，先卖个关子 SpringBoot开发团队认为原始的Spring程序开发的过程也有些繁琐，这个过程仍然可以简化。开发过程无外乎使用什么技术，导入对应的jar包（或坐标）然后将这个技术的核心对象交给Spring容器管理，也就是配置成Spring容器管控的bean就可以了。这都是基本操作啊，难道这些东西SpringBoot也能帮我们简化？ ​\t带着上面这些疑问我们就着手第一个SpringBoot程序的开发了，看看到底使用SpringBoot技术能简化开发到什么程度。\n温馨提示\n​\t如果对Spring程序的基础开发不太懂的小伙伴，看到这里可以弃坑了，下面的内容学习需要具备Spring技术的知识，硬着头皮学不下去的。\nJC-1-1.SpringBoot入门程序制作（一）\r​\t下面让我们开始做第一个SpringBoot程序吧，本课程基于Idea2020.3版本制作，使用的Maven版本为3.6.1，JDK版本为1.8。如果你的环境和上述环境不同，可能在操作界面和操作过程中略有不同，只要软件匹配兼容即可（说到这个Idea和Maven，它们两个还真不是什么版本都能搭到一起的，说多了都是泪啊）。\n​\t下面使用SpringBoot技术快速构建一个SpringMVC的程序，通过这个过程体会简化二字的含义。\n步骤①：创建新模块，选择Spring Initializr，并配置模块相关基础信息\n​\t特别关注：第3步点击Next时，Idea需要联网状态才可以进入到后面那一页，如果不能正常联网，就无法正确到达右面那个设置页了，会一直联网转转转。\n​\t特别关注：第5步选择java版本和你计算机上安装的JDK版本匹配即可，但是最低要求为JDK8或以上版本，推荐使用8或11。\n步骤②：选择当前模块需要使用的技术集\n​\t按照要求，左侧选择web，然后在中间选择Spring Web即可，选完右侧就出现了新的内容项，这就表示勾选成功了。\n​\t关注：此处选择的SpringBoot的版本使用默认的就可以了，需要说一点，SpringBoot的版本升级速度很快，可能昨天创建工程的时候默认版本是2.5.4，今天再创建工程默认版本就变成2.5.5了，差别不大，无需过于纠结，并且还可以到配置文件中修改对应的版本。\n步骤③：开发控制器类\n//Rest模式 @RestController @RequestMapping(\u0026#34;/books\u0026#34;) public class BookController { @GetMapping public String getById(){ System.out.println(\u0026#34;springboot is running...\u0026#34;); return \u0026#34;springboot is running...\u0026#34;; } } ​\t入门案例制作的SpringMVC的控制器基于Rest风格开发，当然此处使用原始格式制作SpringMVC的程序也是没有问题的，上例中的@RestController与@GetMapping注解是基于Restful开发的典型注解。\n​\t关注：做到这里SpringBoot程序的最基础的开发已经做完了，现在就可以正常的运行Spring程序了。可能有些小伙伴会有疑惑，Tomcat服务器没有配置，Spring也没有配置，什么都没有配置这就能用吗？这就是SpringBoot技术的强大之处。关于内部工作流程后面再说，先专心学习开发过程。\n步骤④：运行自动生成的Application类\n​\t使用带main方法的java程序的运行形式来运行程序，运行完毕后，控制台输出上述信息。\n​\t不难看出，运行的信息中包含了8080的端口，Tomcat这种熟悉的字样，难道这里启动了Tomcat服务器？是的，这里已经启动了。那服务器没有配置，哪里来的呢？后面再说。现在你就可以通过浏览器访问请求的路径，测试功能是否工作正常了。\n访问路径：\thttp://localhost:8080/books ​\t是不是感觉很神奇？当前效果其实依赖的底层逻辑还是很复杂的，但是从开发者角度来看，目前只有两个文件展现到了开发者面前。\npom.xml\n这是maven的配置文件，描述了当前工程构建时相应的配置信息。\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;project xmlns=\u0026#34;http://maven.apache.org/POM/4.0.0\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd\u0026#34;\u0026gt; \u0026lt;modelVersion\u0026gt;4.0.0\u0026lt;/modelVersion\u0026gt; \u0026lt;parent\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-parent\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.5.4\u0026lt;/version\u0026gt; \u0026lt;/parent\u0026gt; \u0026lt;groupId\u0026gt;com.itheima\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;springboot_01_01_quickstart\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;0.0.1-SNAPSHOT\u0026lt;/version\u0026gt; \u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-web\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-test\u0026lt;/artifactId\u0026gt; \u0026lt;scope\u0026gt;test\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; \u0026lt;/project\u0026gt; 配置中有两个信息需要关注，一个是parent，也就是当前工程继承了另外一个工程，干什么用的后面再说，还有依赖坐标，干什么用的后面再说。\nApplication类\n@SpringBootApplication public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } } 这个类功能很简单，就一句代码，前面运行程序就是运行的这个类。\n​ 到这里我们可以大胆推测一下，如果上面这两个文件没有的话，SpringBoot肯定没法玩，看来核心就是这两个文件了。由于是制作第一个SpringBoot程序，先不要关注这两个文件的功能，后面详细讲解内部工作流程。\n​ 通过上面的制作，我们不难发现，SpringBoot程序简直太好写了，几乎什么都没写，功能就有了，这也是SpringBoot技术为什么现在这么火的原因，和Spring程序相比，SpringBoot程序在开发的过程中各个层面均具有优势。\n类配置文件 Spring SpringBoot pom文件中的坐标 手工添加 勾选添加 web3.0配置类 手工制作 无 Spring/SpringMVC配置类 手工制作 无 控制器 手工制作 手工制作 ​\t一句话总结一下就是能少写就少写，能不写就不写，这就是SpringBoot技术给我们带来的好处，行了，现在你就可以动手做一做SpringBoot程序了，看看效果如何，是否真的帮助你简化开发了。\n总结\n开发SpringBoot程序在Idea工具中基于联网的前提下可以根据向导快速制作 SpringBoot程序需要依赖JDK，版本要求最低为JDK8 SpringBoot程序中需要使用某种功能时可以通过勾选的形式选择技术，也可以手工添加对应的要使用的技术（后期讲解） 运行SpringBoot程序通过运行Application程序进行 思考\n​\t前面制作的时候说过，这个过程必须联网才可以进行，但是有些时候你会遇到一些莫名其妙的问题，比如基于Idea开发时，你会发现你配置了一些坐标，然后Maven下载对应东西的时候死慢死慢的，甚至还会失败。其实这种现象和Idea这款IDE工具有关，万一Idea不能正常访问网络的话，我们是不是就无法制作SpringBoot程序了呢？咱们下一节再说。\nJC-1-2.SpringBoot入门程序制作（二）\r​\t如果Idea不能正常联网，这个SpringBoot程序就无法制作了吗？开什么玩笑，世上IDE工具千千万，难道SpringBoot技术还必须基于Idea来做了？这是不可能的。开发SpringBoot程序可以不基于IDE工具进行，在SpringBoot官网中可以直接创建SpringBoot程序。\n​\tSpringBoot官网和Spring的官网是在一起的，都是 spring.io 。你可以通过项目一级一级的找到SpringBoot技术的介绍页，然后在页面中间部位找到如下内容\n步骤①：点击Spring Initializr后进入到创建SpringBoot程序界面，接下来就是输入信息的过程，和在Idea中制作是一样的，只是界面发生了变化，根据自己的要求，在左侧选择对应信息和输入对应的信息。\n步骤②：右侧的ADD DEPENDENCIES用于选择使用何种技术，和之前勾选的Spring WEB是在做同一件事，仅仅是界面不同而已，点击后打开网页版的技术选择界面。\n步骤③：所有信息设置完毕后，点击下面左侧GENERATE按钮，生成一个文件包。\n步骤④：保存后得到一个压缩文件，这个文件就是创建的SpringBoot工程\n步骤⑤：解压缩此文件得到工程目录，在Idea中导入即可直接使用，和之前在Idea环境下根据向导创建的工程完全一样，你可以创建一个Controller测试一下当前工程是否可用。\n温馨提示\n​\t做到这里其实可以透漏一个小秘密，Idea工具中创建SpringBoot工程其实连接的就是SpringBoot的官网，还句话说这种方式和第一种方式是一模一样的，只不过Idea把界面给整合了一下，读取Spring官网信息，然后展示到Idea界面中而已，可以通过如下信息比对一下\nIdea中创建工程时默认选项\nSpringBoot官网创建工程时对应的地址\n​\t看看SpringBoot官网创建工程的URL地址，是不是和Idea中使用的URL地址是一样的？\n总结\n打开SpringBoot官网，选择Quickstart Your Project中的Spring Initializr。\n创建工程。\n保存项目文件。\n解压项目，通过IDE导入项目后进行编辑使用。\n思考\n​\t现在创建工程靠的是访问国外的Spring主站，但是互联网信息的访问是可以被约束的，如果一天这个网站你在国内无法访问了，那前面这两种方式就无法创建SpringBoot工程了，这时候又该怎么解决这个问题呢？咱们下一节再说。\nJC-1-3.SpringBoot入门程序制作（三）\r​\t前面提到网站如果被限制访问了，该怎么办？开动脑筋想一想，不管是方式一还是方式二其实走的都是同一个路线，就是通过SpringBoot官网创建SpringBoot工程，假如国内有这么一个网站也能提供这样的功能，是不是就解决了呢？必然的嘛，新的问题又来了，国内有提供这样功能的网站吗？还真有，阿里提供了一个，下面问题就简单了，网址告诉我们就OK了，没错，就是这样。\n​\t创建工程时，切换选择starter服务路径，然后手工输入阿里云地址即可，地址：http://start.aliyun.com或https://start.aliyun.com\n​\t阿里为了便于自己公司开发使用，特此在依赖坐标中添加了一些阿里自主的技术，也是为了推广自己的技术吧，所以在依赖选择列表中，你有了更多的选择。此外，阿里提供的地址更符合国内开发者的使用习惯，里面有一些SpringBoot官网上没有给出的坐标，大家可以好好看一看。\n​\t不过有一点需要说清楚，阿里云地址默认创建的SpringBoot工程版本是2.4.1，所以如果你想更换其他的版本，创建项目后在pom文件中手工修改即可，别忘了刷新一下，加载新版本信息。\n​\t注意：阿里云提供的工程创建地址初始化完毕后和使用SpringBoot官网创建出来的工程略有区别，主要是在配置文件的形式上有区别,这个信息在后面讲解SpringBoot程序的执行流程时给大家揭晓。\n总结\n选择start来源为自定义URL 输入阿里云starter地址 创建项目 思考\n​\t做到这里我们已经有了三种方式创建SpringBoot工程，但是每种方式都要求你必须能上网才能创建工程。假如有一天，你加入了一个保密级别比较高的项目组，整个项目组没有外网，这个事情是不是就不能做了呢？咱们下一节再说。\nJC-1-4.SpringBoot入门程序制作（四）\r​\t不能上网，还想创建SpringBoot工程，能不能做呢？能做，但是你要先问问自己联网和不联网到底差别是什么？这个差别找到以后，你就发现，你把联网要干的事情都提前准备好，就无需联网了。\n​\t联网做什么呢？首先SpringBoot工程也是基于Maven构建的，而Maven工程中如果加载一些工程需要使用又不存在的东西时，就要联网去下载。其实SpringBoot工程创建的时候就是要去下载一些必要的组件。如果把这些东西提前准备好呢？是的，就是这样。\n​\t下面就手工创建一个SpringBoot工程，如果需要使用的东西提前保障在maven仓库中存在，整个过程就可以不依赖联网环境了。不过咱们已经用3种方式创建了SprongBoot工程了，所以下面也没什么东西需要下载了。\n步骤①：创建工程时，选择创建普通Maven工程。\n步骤②：参照标准SpringBoot工程的pom文件，书写自己的pom文件即可。\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;project xmlns=\u0026#34;http://maven.apache.org/POM/4.0.0\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\u0026#34;\u0026gt; \u0026lt;modelVersion\u0026gt;4.0.0\u0026lt;/modelVersion\u0026gt; \u0026lt;parent\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-parent\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.5.4\u0026lt;/version\u0026gt; \u0026lt;/parent\u0026gt; \u0026lt;groupId\u0026gt;com.itheima\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;springboot_01_04_quickstart\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0-SNAPSHOT\u0026lt;/version\u0026gt; \u0026lt;properties\u0026gt; \u0026lt;maven.compiler.source\u0026gt;8\u0026lt;/maven.compiler.source\u0026gt; \u0026lt;maven.compiler.target\u0026gt;8\u0026lt;/maven.compiler.target\u0026gt; \u0026lt;/properties\u0026gt; \u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-web\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; \u0026lt;/project\u0026gt; ​\t用什么写什么，不用的都可以不写。当然，现在小伙伴们可能还不知道用什么和不用什么，最简单的就是复制粘贴了，随着后面的学习，你就知道哪些可以省略了。此处我删减了一些目前不是必须的东西，一样能用。核心的内容有两条，一个是继承了一个父工程，另外添加了一个依赖。\n步骤③：之前运行SpringBoot工程需要一个类，这个缺不了，自己手写一个就行了，建议按照之前的目录结构来创建，先别玩花样，先学走后学跑。类名可以自定义，关联的名称同步修改即可。\n@SpringBootApplication public class Application { public static void main(String[] args) { SpringApplication.run(Application.class); } } ​\t关注：类上面的注解@SpringBootApplication千万别丢了，这个是核心，后面再介绍。\n​\t关注：类名可以自定义，只要保障下面代码中使用的类名和你自己定义的名称一样即可，也就是run方法中的那个class对应的名称。\n步骤④：下面就可以自己创建一个Controller测试一下是否能用了，和之前没有差别的。\n​\t看到这里其实应该能够想明白了，通过向导或者网站创建的SpringBoot工程其实就是帮你写了一些代码，而现在是自己手写，写的内容都一样，仅此而已。\n温馨提示\n​\t如果你的计算机上从来没有创建成功过SpringBoot工程，自然也就没有下载过SpringBoot对应的坐标相关的资源，那用手写创建的方式在不联网的情况下肯定该是不能用的。所谓手写，其实就是自己写别人帮你生成的东西，但是引用的坐标对应的资源必须保障maven仓库里面有才行，如果没有，还是要去下载的。\n总结\n创建普通Maven工程 继承spring-boot-starter-parent 添加依赖spring-boot-starter-web 制作引导类Application ​ 到这里已经学习了4种创建SpringBoot工程的方式，其实本质是一样的，都是根据SpringBoot工程的文件格式要求，通过不同时方式生成或者手写得到对应的文件，效果完全一样。\n教你一招：在Idea中隐藏指定文件/文件夹\r​\t创建SpringBoot工程时，使用SpringBoot向导也好，阿里云也罢，其实都是为了一个目的，得到一个标准的SpringBoot工程文件结构。这个时候就有新的问题出现了，标准的工程结构中包含了一些未知的文件夹，在开发的时候看起来特别别扭，这一节就来说说这些文件怎么处理。\n​\t处理方案无外乎两种，如果你对每一个文件/目录足够了解，有用的留着，没有用的完全可以删除掉。或者不删除，但是看着别扭，就设置文件为看不到就行了。删除不说了，选中后直接Delete掉就好了，这一节说说如何隐藏指定的文件或文件夹信息。\n​\t既然是在Idea下做隐藏功能，肯定隶属于Idea的设置，设置方式如下。\n步骤①：打开设置，【Files】→【Settings】。\n步骤②：打开文件类型设置界面后，【Editor】→【File Types】→【Ignored Files and Folders】，忽略文件或文件夹显示。\n步骤③：添加你要隐藏的文件名称或文件夹名称，可以使用*号通配符，表示任意，设置完毕即可。\n​\t到这里就做完了，其实就是Idea的一个小功能\n总结\nIdea中隐藏指定文件或指定类型文件 【Files】→【Settings】 【Editor】→【File Types】→【Ignored Files and Folders】 输入要隐藏的名称，支持*号通配符 回车确认添加 JC-1-5.SpringBoot简介\r​\t入门案例做完了，这个时候回忆一下咱们之前说的SpringBoot的功能是什么还记得吗？加速Spring程序的开发，现在是否深有体会？再来看SpringBoot技术的设计初衷就很容易理解了。\n​\tSpringBoot是由Pivotal团队提供的全新框架，其设计目的是用来简化Spring应用的初始搭建以及开发过程。\n​\t都简化了了哪些东西呢？其实就是针对原始的Spring程序制作的两个方面进行了简化：\nSpring程序缺点 依赖设置繁琐 以前写Spring程序，使用的技术都要自己一个一个的写，现在不需要了，如果做过原始SpringMVC程序的小伙伴应该知道，写SpringMVC程序，最基础的spring-web和spring-webmvc这两个坐标是必须的，就这还不包含你用json啊等等这些坐标，现在呢？一个坐标搞定了。 配置繁琐 以前写配置类或者配置文件，然后用什么东西就要自己写加载bean这些东西，现在呢？什么都没写，照样能用。 回顾\n​\t通过上面两个方面的定位，我们可以产生两个模糊的概念：\nSpringBoot开发团队认为原始的Spring程序初始搭建的时候可能有些繁琐，这个过程是可以简化的，那原始的Spring程序初始搭建过程都包含哪些东西了呢？为什么觉得繁琐呢？最基本的Spring程序至少有一个配置文件或配置类，用来描述Spring的配置信息，莫非这个文件都可以不写？此外现在企业级开发使用Spring大部分情况下是做web开发，如果做web开发的话，还要在加载web环境时加载时加载指定的spring配置，这都是最基本的需求了，不写的话怎么知道加载哪个配置文件/配置类呢？那换了SpringBoot技术以后呢，这些还要写吗？谜底稍后揭晓，先卖个关子 SpringBoot开发团队认为原始的Spring程序开发的过程也有些繁琐，这个过程仍然可以简化。开发过程无外乎使用什么技术，导入对应的jar包（或坐标）然后将这个技术的核心对象交给Spring容器管理，也就是配置成Spring容器管控的bean就可以了。这都是基本操作啊，难道这些东西SpringBoot也能帮我们简化？ ​\t再来看看前面提出的两个问题，已经有答案了，都简化了，都不用写了，这就是SpringBoot给我们带来的好处。这些简化操作在SpringBoot中有专业的用语，也是SpringBoot程序的核心功能及优点：\n起步依赖（简化依赖配置） 依赖配置的书写简化就是靠这个起步依赖达成的。 自动配置（简化常用工程相关配置） 配置过于繁琐，使用自动配置就可以做相应的简化，但是内部还是很复杂的，后面具体展开说。 辅助功能（内置服务器，……） 除了上面的功能，其实SpringBoot程序还有其他的一些优势，比如我们没有配置Tomcat服务器，但是能正常运行，这是SpringBoot入门程序中一个可以感知到的功能，也是SpringBoot的辅助功能之一。一个辅助功能都能做的这么6，太牛了。 ​\t下面结合入门程序来说说这些简化操作都在哪些方面进行体现的，一共分为4个方面\nparent starter 引导类 内嵌tomcat parent\r​\tSpringBoot关注到开发者在进行开发时，往往对依赖版本的选择具有固定的搭配格式，并且这些依赖版本的选择还不能乱搭配。比如A技术的2.0版，在与B技术进行配合使用时，与B技术的3.5版可以合作在一起工作，但是和B技术的3.7版合作开发使用时就有冲突。其实很多开发者都一直想做一件事情，就是将各种各样的技术配合使用的常见依赖版本进行收集整理，制作出了最合理的依赖版本配置方案，这样使用起来就方便多了。\n​\tSpringBoot一看这种情况so easy啊，于是将所有的技术版本的常见使用方案都给开发者整理了出来，以后开发者使用时直接用它提供的版本方案，就不用担心冲突问题了，相当于SpringBoot做了无数个技术版本搭配的列表，这个技术搭配列表的名字叫做parent。\n​\tparent自身具有很多个版本，每个parent版本中包含有几百个其他技术的版本号，不同的parent间使用的各种技术的版本号有可能会发生变化。当开发者使用某些技术时，直接使用SpringBoot提供的parent就行了，由parent帮助开发者统一的进行各种技术的版本管理。\n​\t比如你现在要使用Spring配合MyBatis开发，没有parent之前怎么做呢？选个Spring的版本，再选个MyBatis的版本，再把这些技术使用时关联的其他技术的版本逐一确定下来。当你Spring的版本发生变化需要切换时，你的MyBatis版本有可能也要跟着切换，关联技术呢？可能都要切换，而且切换后还可能出现其他问题。现在这一切工作都可以交给parent来做了。你无需关注这些技术间的版本冲突问题，你只需要关注你用什么技术就行了，冲突问题由parent负责处理。\n​\t有人可能会提出来，万一parent给我导入了一些我不想使用的依赖怎么办？记清楚，这一点很关键，parent仅仅帮我们进行版本管理，它不负责帮你导入坐标，说白了用什么还是你自己定，只不过版本不需要你管理了。整体上来说，使用parent可以帮助开发者进行版本的统一管理。\n​\t关注：parent定义出来以后，并不是直接使用的，仅仅给了开发者一个说明书，但是并没有实际使用，这个一定要确认清楚。\n​\t那SpringBoot又是如何做到这一点的呢？可以查阅SpringBoot的配置源码，看到这些定义。\n项目中的pom.xml中继承了一个坐标 \u0026lt;parent\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-parent\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.5.4\u0026lt;/version\u0026gt; \u0026lt;/parent\u0026gt; 打开后可以查阅到其中又继承了一个坐标 \u0026lt;parent\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-dependencies\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.5.4\u0026lt;/version\u0026gt; \u0026lt;/parent\u0026gt; 这个坐标中定义了两组信息 第一组是各式各样的依赖版本号属性，下面列出依赖版本属性的局部，可以看的出来，定义了若干个技术的依赖版本号。\n\u0026lt;properties\u0026gt; \u0026lt;activemq.version\u0026gt;5.16.3\u0026lt;/activemq.version\u0026gt; \u0026lt;aspectj.version\u0026gt;1.9.7\u0026lt;/aspectj.version\u0026gt; \u0026lt;assertj.version\u0026gt;3.19.0\u0026lt;/assertj.version\u0026gt; \u0026lt;commons-codec.version\u0026gt;1.15\u0026lt;/commons-codec.version\u0026gt; \u0026lt;commons-dbcp2.version\u0026gt;2.8.0\u0026lt;/commons-dbcp2.version\u0026gt; \u0026lt;commons-lang3.version\u0026gt;3.12.0\u0026lt;/commons-lang3.version\u0026gt; \u0026lt;commons-pool.version\u0026gt;1.6\u0026lt;/commons-pool.version\u0026gt; \u0026lt;commons-pool2.version\u0026gt;2.9.0\u0026lt;/commons-pool2.version\u0026gt; \u0026lt;h2.version\u0026gt;1.4.200\u0026lt;/h2.version\u0026gt; \u0026lt;hibernate.version\u0026gt;5.4.32.Final\u0026lt;/hibernate.version\u0026gt; \u0026lt;hibernate-validator.version\u0026gt;6.2.0.Final\u0026lt;/hibernate-validator.version\u0026gt; \u0026lt;httpclient.version\u0026gt;4.5.13\u0026lt;/httpclient.version\u0026gt; \u0026lt;jackson-bom.version\u0026gt;2.12.4\u0026lt;/jackson-bom.version\u0026gt; \u0026lt;javax-jms.version\u0026gt;2.0.1\u0026lt;/javax-jms.version\u0026gt; \u0026lt;javax-json.version\u0026gt;1.1.4\u0026lt;/javax-json.version\u0026gt; \u0026lt;javax-websocket.version\u0026gt;1.1\u0026lt;/javax-websocket.version\u0026gt; \u0026lt;jetty-el.version\u0026gt;9.0.48\u0026lt;/jetty-el.version\u0026gt; \u0026lt;junit.version\u0026gt;4.13.2\u0026lt;/junit.version\u0026gt; \u0026lt;/properties\u0026gt; 第二组是各式各样的依赖坐标信息，可以看出依赖坐标定义中没有具体的依赖版本号，而是引用了第一组信息中定义的依赖版本属性值.\n\u0026lt;dependencyManagement\u0026gt; \u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.hibernate\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;hibernate-core\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;${hibernate.version}\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;junit\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;junit\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;${junit.version}\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; \u0026lt;/dependencyManagement\u0026gt; ​\t关注：上面的依赖坐标定义是出现在标签中的，是对引用坐标的依赖管理，并不是实际使用的坐标。因此当你的项目中继承了这组parent信息后，在不使用对应坐标的情况下，前面的这组定义是不会具体导入某个依赖的。\n​\t关注：因为在maven中继承机会只有一次，上述继承的格式还可以切换成导入的形式进行，并且在阿里云的starter创建工程时就使用了此种形式。\n\u0026lt;dependencyManagement\u0026gt; \u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-dependencies\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;${spring-boot.version}\u0026lt;/version\u0026gt; \u0026lt;type\u0026gt;pom\u0026lt;/type\u0026gt; \u0026lt;scope\u0026gt;import\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; \u0026lt;/dependencyManagement\u0026gt; 总结\n开发SpringBoot程序要继承spring-boot-starter-parent spring-boot-starter-parent中定义了若干个依赖管理 继承parent模块可以避免多个依赖使用相同技术时出现依赖版本冲突 继承parent的形式也可以采用引入依赖的形式实现效果 思考\n​\tparent中定义了若干个依赖版本管理，但是也没有使用，那这个设定也就不生效啊，究竟谁在使用这些定义呢？\nstarter\r​\tSpringBoot关注到实际开发时，开发者对于依赖坐标的使用往往都有一些固定的组合方式，比如使用spring-webmvc就一定要使用spring-web。每次都要固定搭配着写，非常繁琐，而且格式固定，没有任何技术含量。\n​\tSpringBoot一看这种情况，看来需要给开发者带来一些帮助了。安排，把所有的技术使用的固定搭配格式都给开发出来，以后你用某个技术，就不用每次写一堆依赖了，还容易写错，我给你做一个东西，代表一堆东西，开发者使用的时候，直接用我做好的这个东西就好了，对于这样的固定技术搭配，SpringBoot给它起了个名字叫做starter。\n​\tstarter定义了使用某种技术时对于依赖的固定搭配格式，也是一种最佳解决方案，使用starter可以帮助开发者减少依赖配置。\n​\t这个东西其实在入门案例里面已经使用过了，入门案例中的web功能就是使用这种方式添加依赖的。可以查阅SpringBoot的配置源码，看到这些定义。\n项目中的pom.xml定义了使用SpringMVC技术，但是并没有写SpringMVC的坐标，而是添加了一个名字中包含starter的依赖 \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-web\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; 在spring-boot-starter-web中又定义了若干个具体依赖的坐标 \u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.5.4\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;compile\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-json\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.5.4\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;compile\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-tomcat\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.5.4\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;compile\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-web\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;5.3.9\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;compile\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-webmvc\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;5.3.9\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;compile\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; ​\t之前提到过开发SpringMVC程序需要导入spring-webmvc的坐标和spring整合web开发的坐标，就是上面这组坐标中的最后两个了。\n​\t但是我们发现除了这两个坐标，还有其他的坐标。比如第二个，叫做spring-boot-starter-json。看名称就知道，这个是与json有关的坐标了，但是看名字发现和最后两个又不太一样，它的名字中也有starter，打开看看里面有什么？\n\u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.5.4\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;compile\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-web\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;5.3.9\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;compile\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.fasterxml.jackson.core\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;jackson-databind\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.12.4\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;compile\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.fasterxml.jackson.datatype\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;jackson-datatype-jdk8\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.12.4\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;compile\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.fasterxml.jackson.datatype\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;jackson-datatype-jsr310\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.12.4\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;compile\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.fasterxml.jackson.module\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;jackson-module-parameter-names\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.12.4\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;compile\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; ​\t我们可以发现，这个starter中又包含了若干个坐标，其实就是使用SpringMVC开发通常都会使用到Json，使用json又离不开这里面定义的这些坐标，看来还真是方便，SpringBoot把我们开发中使用的东西能用到的都给提前做好了。你仔细看完会发现，里面有一些你没用过的。的确会出现这种过量导入的可能性，没关系，可以通过maven中的排除依赖剔除掉一部分。不过你不管它也没事，大不了就是过量导入呗。\n​\t到这里基本上得到了一个信息，使用starter可以帮开发者快速配置依赖关系。以前写依赖3个坐标的，现在写导入一个就搞定了，就是加速依赖配置的。\nstarter与parent的区别\n​\t朦朦胧胧中感觉starter与parent好像都是帮助我们简化配置的，但是功能又不一样，梳理一下。\n​\tstarter是一个坐标中定了若干个坐标，以前写多个的，现在写一个，是用来减少依赖配置的书写量的。\n​\tparent是定义了几百个依赖版本号，以前写依赖需要自己手工控制版本，现在由SpringBoot统一管理，这样就不存在版本冲突了，是用来减少依赖冲突的。\n实际开发应用方式\n实际开发中如果需要用什么技术，先去找有没有这个技术对应的starter\n如果有对应的starter，直接写starter，而且无需指定版本，版本由parent提供 如果没有对应的starter，手写坐标即可 实际开发中如果发现坐标出现了冲突现象，确认你要使用的可行的版本号，使用手工书写的方式添加对应依赖，覆盖SpringBoot提供给我们的配置管理\n方式一：直接写坐标 方式二：覆盖中定义的版本号，就是下面这堆东西了，哪个冲突了覆盖哪个就OK了 \u0026lt;properties\u0026gt; \u0026lt;activemq.version\u0026gt;5.16.3\u0026lt;/activemq.version\u0026gt; \u0026lt;aspectj.version\u0026gt;1.9.7\u0026lt;/aspectj.version\u0026gt; \u0026lt;assertj.version\u0026gt;3.19.0\u0026lt;/assertj.version\u0026gt; \u0026lt;commons-codec.version\u0026gt;1.15\u0026lt;/commons-codec.version\u0026gt; \u0026lt;commons-dbcp2.version\u0026gt;2.8.0\u0026lt;/commons-dbcp2.version\u0026gt; \u0026lt;commons-lang3.version\u0026gt;3.12.0\u0026lt;/commons-lang3.version\u0026gt; \u0026lt;commons-pool.version\u0026gt;1.6\u0026lt;/commons-pool.version\u0026gt; \u0026lt;commons-pool2.version\u0026gt;2.9.0\u0026lt;/commons-pool2.version\u0026gt; \u0026lt;h2.version\u0026gt;1.4.200\u0026lt;/h2.version\u0026gt; \u0026lt;hibernate.version\u0026gt;5.4.32.Final\u0026lt;/hibernate.version\u0026gt; \u0026lt;hibernate-validator.version\u0026gt;6.2.0.Final\u0026lt;/hibernate-validator.version\u0026gt; \u0026lt;httpclient.version\u0026gt;4.5.13\u0026lt;/httpclient.version\u0026gt; \u0026lt;jackson-bom.version\u0026gt;2.12.4\u0026lt;/jackson-bom.version\u0026gt; \u0026lt;javax-jms.version\u0026gt;2.0.1\u0026lt;/javax-jms.version\u0026gt; \u0026lt;javax-json.version\u0026gt;1.1.4\u0026lt;/javax-json.version\u0026gt; \u0026lt;javax-websocket.version\u0026gt;1.1\u0026lt;/javax-websocket.version\u0026gt; \u0026lt;jetty-el.version\u0026gt;9.0.48\u0026lt;/jetty-el.version\u0026gt; \u0026lt;junit.version\u0026gt;4.13.2\u0026lt;/junit.version\u0026gt; \u0026lt;/properties\u0026gt; 温馨提示\n​\tSpringBoot官方给出了好多个starter的定义，方便我们使用，而且名称都是如下格式\n命名规则：spring-boot-starter-技术名称 ​\t所以后期见了spring-boot-starter-aaa这样的名字，这就是SpringBoot官方给出的starter定义。那非官方定义的也有吗？有的，具体命名方式到整合技术的章节再说。\n总结\n开发SpringBoot程序需要导入坐标时通常导入对应的starter 每个不同的starter根据功能不同，通常包含多个依赖坐标 使用starter可以实现快速配置的效果，达到简化配置的目的 引导类\r​\t配置说完了，我们发现SpringBoot确实帮助我们减少了很多配置工作，下面说一下程序是如何运行的。目前程序运行的入口就是SpringBoot工程创建时自带的那个类，也就是带有main方法的那个类，运行这个类就可以启动SpringBoot工程的运行。\n@SpringBootApplication public class Springboot0101QuickstartApplication { public static void main(String[] args) { SpringApplication.run(Springboot0101QuickstartApplication.class, args); } } ​\tSpringBoot本身是为了加速Spring程序的开发的，而Spring程序运行的基础是需要创建Spring容器对象（IoC容器）并将所有的对象放置到Spring容器中管理，也就是一个一个的Bean。现在改用SpringBoot加速开发Spring程序，这个容器还在吗？这个疑问不用说，一定在。其实当前这个类运行后就会产生一个Spring容器对象，并且可以将这个对象保存起来，通过容器对象直接操作Bean。\n@SpringBootApplication public class QuickstartApplication { public static void main(String[] args) { ConfigurableApplicationContext ctx = SpringApplication.run(QuickstartApplication.class, args); BookController bean = ctx.getBean(BookController.class); System.out.println(\u0026#34;bean======\u0026gt;\u0026#34; + bean); } } ​\t通过上述操作不难看出，其实SpringBoot程序启动还是创建了一个Spring容器对象。当前运行的这个类在SpringBoot程序中是所有功能的入口，称为引导类。\n​\t作为一个引导类最典型的特征就是当前类上方声明了一个注解@SpringBootApplication。\n总结\nSpringBoot工程提供引导类用来启动程序 SpringBoot工程启动后创建并初始化Spring容器 思考\n​\t程序现在已经运行了，通过引导类的main方法运行了起来。但是运行java程序不应该是执行完就结束了吗？但是我们现在明显是启动了一个web服务器啊，不然网页怎么能正常访问呢？这个服务器是在哪里写的呢？\n内嵌tomcat\r​\t当前我们做的SpringBoot入门案例勾选了Spring-web的功能，并且导入了对应的starter。\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-web\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; ​\tSpringBoot发现，既然你要做web程序，肯定离不开使用web服务器，这样吧，帮人帮到底，送佛送到西，我帮你搞一个web服务器，你要愿意用的，直接使用就好了。SpringBoot又琢磨，提供一种服务器万一不满足开发者需要呢？干脆我再多给你几种选择，你随便切换。万一你不想用我给你提供的，也行，你可以自己搞。\n​\t由于这个功能不属于程序的主体功能，可用可不用，于是乎SpringBoot将其定位成辅助功能，别小看这么一个辅助功能，它可是帮我们开发者又减少了好多的设置性工作。\n​\t下面就围绕着这个内置的web服务器，也可以说是内置的tomcat服务器来研究几个问题：\n这个服务器在什么位置定义的 这个服务器是怎么运行的 这个服务器如果想换怎么换？虽然这个需求很垃圾，搞得开发者会好多web服务器一样，用别人提供好的不香么？非要自己折腾 内嵌Tomcat定义位置\n​\t说到定义的位置，我们就想，如果我们不开发web程序，用的着web服务器吗？肯定用不着啊。那如果这个东西被加入到你的程序中，伴随着什么技术进来的呢？肯定是web相关的功能啊，没错，就是前面导入的web相关的starter做的这件事。\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-web\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; ​\t打开web对应的starter查看导入了哪些东西。\n\u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.5.4\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;compile\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-json\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.5.4\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;compile\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-tomcat\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.5.4\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;compile\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-web\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;5.3.9\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;compile\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-webmvc\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;5.3.9\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;compile\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; ​\t第三个依赖就是tomcat对应的东西了，居然也是一个starter，再打开看看。\n\u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;jakarta.annotation\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;jakarta.annotation-api\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.3.5\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;compile\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.apache.tomcat.embed\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;tomcat-embed-core\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;9.0.52\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;compile\u0026lt;/scope\u0026gt; \u0026lt;exclusions\u0026gt; \u0026lt;exclusion\u0026gt; \u0026lt;artifactId\u0026gt;tomcat-annotations-api\u0026lt;/artifactId\u0026gt; \u0026lt;groupId\u0026gt;org.apache.tomcat\u0026lt;/groupId\u0026gt; \u0026lt;/exclusion\u0026gt; \u0026lt;/exclusions\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.apache.tomcat.embed\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;tomcat-embed-el\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;9.0.52\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;compile\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.apache.tomcat.embed\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;tomcat-embed-websocket\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;9.0.52\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;compile\u0026lt;/scope\u0026gt; \u0026lt;exclusions\u0026gt; \u0026lt;exclusion\u0026gt; \u0026lt;artifactId\u0026gt;tomcat-annotations-api\u0026lt;/artifactId\u0026gt; \u0026lt;groupId\u0026gt;org.apache.tomcat\u0026lt;/groupId\u0026gt; \u0026lt;/exclusion\u0026gt; \u0026lt;/exclusions\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; ​\t这里面有一个核心的坐标，tomcat-embed-core，叫做tomcat内嵌核心。就是这个东西把tomcat功能引入到了我们的程序中的。目前解决了第一个问题，找到根儿了，谁把tomcat引入到程序中的？spring-boot-starter-web中的spring-boot-starter-tomcat做的。之所以你感觉很奇妙的原因就是，这个东西是默认加入到程序中了，所以感觉很神奇，居然什么都不做，就有了web服务器对应的功能。再来说第二个问题，这个服务器是怎么运行的。\n内嵌Tomcat运行原理\n​\tTomcat服务器是一款软件，而且是一款使用java语言开发的软件，熟悉tomcat的话应该知道tomcat安装目录中保存有很多jar文件。\n​\t下面的问题来了，既然是使用java语言开发的，运行的时候肯定符合java程序运行的原理，java程序运行靠的是什么？对象呀，一切皆对象，万物皆对象。那tomcat运行起来呢？也是对象啊。\n​\t如果是对象，那Spring容器是用来管理对象的，这个对象能交给Spring容器管理吗？把吗去掉，是个对象都可以交给Spring容器管理，行了，这下通了，tomcat服务器运行其实是以对象的形式在Spring容器中运行的。怪不得我们没有安装这个tomcat但是还能用，闹了白天这东西最后是以一个对象的形式存在，保存在Spring容器中悄悄运行的。具体运行的是什么呢？其实就是上前面提到的那个tomcat内嵌核心。\n\u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.apache.tomcat.embed\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;tomcat-embed-core\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;9.0.52\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;compile\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; ​\t那既然是个对象，如果把这个对象从Spring容器中去掉是不是就没有web服务器的功能呢？是这样的，通过依赖排除可以去掉这个web服务器功能。\n\u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-web\u0026lt;/artifactId\u0026gt; \u0026lt;exclusions\u0026gt; \u0026lt;exclusion\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-tomcat\u0026lt;/artifactId\u0026gt; \u0026lt;/exclusion\u0026gt; \u0026lt;/exclusions\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; ​\t上面对web-starter做了一个操作，使用maven的排除依赖去掉了使用tomcat的starter。这下好了，容器中肯定没有这个对象了，重新启动程序可以观察到程序运行了，但是并没有像之前那样运行后是一个一直运行的服务，而是直接停掉了，就是这个原因。\n更换内嵌Tomcat\n​\t那根据上面的操作我们思考是否可以换个服务器呢？必须的嘛。根据SpringBoot的工作机制，用什么技术，加入什么依赖就行了。SpringBoot提供了3款内置的服务器：\ntomcat(默认)：apache出品，粉丝多，应用面广，负载了若干较重的组件\njetty：更轻量级，负载性能远不及tomcat\nundertow：负载性能勉强跑赢tomcat\n想用哪个，加个坐标就OK。前提是把tomcat排除掉，因为tomcat是默认加载的。\n\u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-web\u0026lt;/artifactId\u0026gt; \u0026lt;exclusions\u0026gt; \u0026lt;exclusion\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-tomcat\u0026lt;/artifactId\u0026gt; \u0026lt;/exclusion\u0026gt; \u0026lt;/exclusions\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-jetty\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; ​\t现在就已经成功替换了web服务器，核心思想就是用什么加入对应坐标就可以了。如果有starter，优先使用starter。\n总结\n内嵌Tomcat服务器是SpringBoot辅助功能之一 内嵌Tomcat工作原理是将Tomcat服务器作为对象运行，并将该对象交给Spring容器管理 变更内嵌服务器思想是去除现有服务器，添加全新的服务器 ​\t到这里第一章快速上手SpringBoot就结束了，这一章我们学习了两大块知识\n使用了4种方式制作了SpringBoot的入门程序，不管是哪一种，其实内部都是一模一样的\n学习了入门程序的工作流程，知道什么是parent，什么是starter，这两个东西是怎么配合工作的，以及我们的程序为什么启动起来是一个tomcat服务器等等\n第一章到这里就结束了，再往下学习就要去基于会创建SpringBoot工程的基础上，研究SpringBoot工程的具体细节了。\nJC-2.SpringBoot基础配置\r​\t入门案例做完了，下面就要研究SpringBoot的用法了。通过入门案例，各位小伙伴能够感知到一个信息，SpringBoot没有具体的功能，它是辅助加快Spring程序的开发效率的。我们发现，现在几乎不用做任何配置功能就有了，确实很好用。但是仔细想想，没有做配置意味着什么？意味着配置已经做好了，不用你自己写了。但是新的问题又来了，如果不想用已经写好的默认配置，该如何干预呢？这就是这一章咱们要研究的问题。\n​\t如果想修改默认的配置，这个信息应该写在什么位置呢？目前我们接触的入门案例中一共有3个文件，第一是pom.xml文件，设置项目的依赖，这个没什么好研究的，相关的高级内容咱们到原理篇再说，第二是引导类，这个是执行SpringBoot程序的入口，也不像是做功能配置的地方，其实还有一个信息，就是在resources目录下面有一个空白的文件，叫做application.properties。一看就是个配置文件，咱们这一章就来说说配置文件怎么写，能写什么，怎么覆盖SpringBoot的默认配置修改成自己的配置。\n​\nJC-2-1.属性配置\r​\tSpringBoot通过配置文件application.properties就可以修改默认的配置，那咱们就先找个简单的配置下手，当前访问tomcat的默认端口是8080，好熟悉的味道，但是不便于书写，我们先改成80，通过这个操作来熟悉一下SpringBoot的配置格式是什么样的。\n​\t那该如何写呢？properties格式的文件书写规范是key=value\nname=itheima ​\t这个格式肯定是不能颠覆的，那就尝试性的写就行了，改端口，写port。当你输入port后，神奇的事情就发生了，这玩意儿带提示，太好了。\n​\t根据提示敲回车，输入80端口，搞定。\nserver.port=80 ​\t下面就可以直接运行程序，测试效果了。\n​\t我们惊奇的发现SpringBoot这玩意儿狠啊，以前修改端口在哪里改？tomcat服务器的配置文件中改，现在呢？SpringBoot专用的配置文件中改，是不是意味着以后所有的配置都可以写在这一个文件中呢？是的，简化开发者配置的书写位置，集中管理。妙啊，妈妈再也不用担心我找不到配置文件了。\n​\t其实到这里我们应该得到如下三个信息：\nSpringBoot程序可以在application.properties文件中进行属性配置 application.properties文件中只要输入要配置的属性关键字就可以根据提示进行设置 SpringBoot将配置信息集中在一个文件中写，不管你是服务器的配置，还是数据库的配置，总之都写在一起，逃离一个项目十几种配置文件格式的尴尬局面 总结\nSpringBoot默认配置文件是application.properties ​\t做完了端口的配置，趁热打铁，再做几个配置，目前项目启动时会显示一些日志信息，就来改一改这里面的一些设置。\n关闭运行日志图表（banner)\nspring.main.banner-mode=off 设置运行日志的显示级别\nlogging.level.root=debug ​\t你会发现，现在这么搞配置太爽了，以前你做配置怎么做？不同的技术有自己专用的配置文件，文件不同格式也不统一，现在呢？不用东奔西走的找配置文件写配置了，统一格式了，这就是大秦帝国啊，统一六国。SpringBoot比大秦狠，因为未来出现的技术还没出现呢，但是现在已经确认了，配置都写这个文件里面。\n​\t我们现在配置了3个信息，但是又有新的问题了。这个配置是随便写的吗？什么都能配？有没有一个东西显示所有能配置的项呢？此外这个配置和什么东西有关呢？会不会因为我写了什么东西以后才可以写什么配置呢？比如我现在没有写数据库相关的东西，能否配置数据呢？一个一个来，先说第一个问题，都能配置什么。\n​\t打开SpringBoot的官网，找到SpringBoot官方文档，打开查看附录中的Application Properties就可以获取到对应的配置项了，网址奉上：https://docs.spring.io/spring-boot/docs/current/reference/html/application-properties.html#application-properties\n​\t能写什么的问题解决了，再来说第二个问题，这个配置项和什么有关。在pom中注释掉导入的spring-boot-starter-web，然后刷新工程，你会发现配置的提示消失了。闹了半天是设定使用了什么技术才能做什么配置。也合理，不然没有使用对应技术，配了也是白配。\n温馨提示\n​\t所有的starter中都会依赖下面这个starter，叫做spring-boot-starter。这个starter是所有的SpringBoot的starter的基础依赖，里面定义了SpringBoot相关的基础配置，关于这个starter我们到开发应用篇和原理篇中再深入讲解。\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.5.4\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;compile\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; 总结\nSpringBoot中导入对应starter后，提供对应配置属性 书写SpringBoot配置采用关键字+提示形式书写 JC-2-2.配置文件分类\r​\t现在已经能够进行SpringBoot相关的配置了，但是properties格式的配置写起来总是觉得看着不舒服，所以就期望存在一种书写起来更简便的配置格式提供给开发者使用。有吗？还真有，SpringBoot除了支持properties格式的配置文件，还支持另外两种格式的配置文件。三种配置文件格式分别如下:\nproperties格式 yml格式 yaml格式 ​\t一看到全新的文件格式，各位小伙伴肯定想，这下又要学习新的语法格式了。怎么说呢？从知识角度来说，要学，从开发角度来说，不用学。为什么呢？因为SpringBoot的配置在Idea工具下有提示啊，跟着提示走就行了。下面列举三种不同文件格式配置相同的属性范例，先了解一下。\napplication.properties（properties格式） server.port=80 application.yml（yml格式） server:\rport: 81 application.yaml（yaml格式） server: port: 82 ​\t仔细看会发现yml格式和yaml格式除了文件名后缀不一样，格式完全一样，是这样的，yml和yaml文件格式就是一模一样的，只是文件后缀不同，所以可以合并成一种格式来看。那对于这三种格式来说，以后用哪一种比较多呢？记清楚，以后基本上都是用yml格式的，本课程后面的所有知识都是基于yml格式来制作的，以后在企业开发过程中用这个格式的机会也最多，一定要重点掌握。\n总结\nSpringBoot提供了3种配置文件的格式 properties（传统格式/默认格式） yml（主流格式） yaml 思考\n​\t现在我们已经知道使用三种格式都可以做配置了，好奇宝宝们就有新的灵魂拷问了，万一我三个都写了，他们三个谁说了算呢？打一架吗？\n配置文件优先级\r​\t其实三个文件如果共存的话，谁生效说的就是配置文件加载的优先级别。先说一点，虽然以后这种情况很少出现，但是这个知识还是可以学习一下的。我们就让三个配置文件书写同样的信息，比如都配置端口，然后我们让每个文件配置的端口号都不一样，最后启动程序后看启动端口是多少就知道谁的加载优先级比较高了。\napplication.properties（properties格式） server.port=80 application.yml（yml格式） server:\rport: 81 application.yaml（yaml格式） server: port: 82 ​\t启动后发现目前的启动端口为80，把80对应的文件删除掉，然后再启动，现在端口又改成了81。现在我们就已经知道了3个文件的加载优先顺序是什么。\napplication.properties \u0026gt; application.yml \u0026gt; application.yaml ​\t虽然得到了一个知识结论，但是我们实际开发的时候还是要看最终的效果为准。也就是你要的最终效果是什么自己是明确的，上述结论只能帮助你分析结论产生的原因。这个知识了解一下就行了，因为以后同时写多种配置文件格式的情况实在是较少。\n​\t最后我们把配置文件内容给修改一下\napplication.properties（properties格式） server.port=80 spring.main.banner-mode=off application.yml（yml格式） server:\rport: 81\rlogging: level: root: debug application.yaml（yaml格式） server: port: 82 ​\t我们发现不仅端口生效了，最终显示80，同时其他两条配置也生效了，看来每个配置文件中的项都会生效，只不过如果多个配置文件中有相同类型的配置会优先级高的文件覆盖优先级的文件中的配置。如果配置项不同的话，所有的配置项都会生效。\n总结\n配置文件间的加载优先级\tproperties（最高）\u0026gt; yml \u0026gt; yaml（最低） 不同配置文件中相同配置按照加载优先级相互覆盖，不同配置文件中不同配置全部保留 教你一招：自动提示功能消失解决方案\r​\t在做程序的过程中，可能有些小伙伴会基于各种各样的原因导致配置文件中没有提示，这个确实很让人头疼，所以下面给大家说一下如果自动提示功能消失了怎么解决。\n​\t先要明确一个核心，就是自动提示功能不是SpringBoot技术给我们提供的，是我们在Idea工具下编程，这个编程工具给我们提供的。明白了这一点后，再来说为什么会出现这种现象。其实这个自动提示功能消失的原因还是蛮多的，如果想解决这个问题，就要知道为什么会消失，大体原因有如下2种：\nIdea认为你现在写配置的文件不是个配置文件，所以拒绝给你提供提示功能\nIdea认定你是合理的配置文件，但是Idea加载不到对应的提示信息\n这里我们主要解决第一个现象，第二种现象到原理篇再讲解。第一种现象的解决方式如下：\n步骤①：打开设置，【Files】→【Project Structure\u0026hellip;】\n步骤②：在弹出窗口中左侧选择【Facets】，右侧选中Spring路径下对应的模块名称，也就是你自动提示功能消失的那个模块\n步骤③：点击Customize Spring Boot按钮，此时可以看到当前模块对应的配置文件是哪些了。如果没有你想要称为配置文件的文件格式，就有可能无法弹出提示\n步骤④：选择添加配置文件，然后选中要作为配置文件的具体文件就OK了\n​\t到这里就做完了，其实就是Idea的一个小功能\n总结\n指定SpringBoot配置文件\nSetting → Project Structure → Facets 选中对应项目/工程 Customize Spring Boot 选择配置文件 JC-2-3.yaml文件\r​\tSpringBoot的配置以后主要使用yml结尾的这种文件格式，并且在书写时可以通过提示的形式加载正确的格式。但是这种文件还是有严格的书写格式要求的。下面就来说一下具体的语法格式。\n​\tYAML（YAML Ain\u0026rsquo;t Markup Language），一种数据序列化格式。具有容易阅读、容易与脚本语言交互、以数据为核心，重数据轻格式的特点。常见的文件扩展名有两种：\n.yml格式（主流）\n.yaml格式\n具体的语法格式要求如下：\n大小写敏感 属性层级关系使用多行描述，每行结尾使用冒号结束 使用缩进表示层级关系，同层级左侧对齐，只允许使用空格（不允许使用Tab键） 属性值前面添加空格（属性名与属性值之间使用冒号+空格作为分隔） #号 表示注释 ​\t上述规则不要死记硬背，按照书写习惯慢慢适应，并且在Idea下由于具有提示功能，慢慢适应着写格式就行了。核心的一条规则要记住，数据前面要加空格与冒号隔开。\n​\t下面列出常见的数据书写格式，熟悉一下\nboolean: TRUE #TRUE,true,True,FALSE,false，False均可 float: 3.14 #6.8523015e+5 #支持科学计数法 int: 123 #0b1010_0111_0100_1010_1110 #支持二进制、八进制、十六进制 null: ~ #使用~表示null string: HelloWorld #字符串可以直接书写 string2: \u0026#34;Hello World\u0026#34; #可以使用双引号包裹特殊字符 date: 2018-02-17 #日期必须使用yyyy-MM-dd格式 datetime: 2018-02-17T15:02:31+08:00 #时间和日期之间使用T连接，最后使用+代表时区 ​\t此外，yaml格式中也可以表示数组，在属性名书写位置的下方使用减号作为数据开始符号，每行书写一个数据，减号与数据间空格分隔。\nsubject: - Java - 前端 - 大数据 enterprise: name: itcast age: 16 subject: - Java - 前端 - 大数据 likes: [王者荣耀,刺激战场]\t#数组书写缩略格式 users:\t#对象数组格式一 - name: Tom age: 4 - name: Jerry age: 5 users:\t#对象数组格式二 - name: Tom age: 4 - name: Jerry age: 5\tusers2: [ { name:Tom , age:4 } , { name:Jerry , age:5 } ]\t#对象数组缩略格式 总结\nyaml语法规则 大小写敏感 属性层级关系使用多行描述，每行结尾使用冒号结束 使用缩进表示层级关系，同层级左侧对齐，只允许使用空格（不允许使用Tab键） 属性值前面添加空格（属性名与属性值之间使用冒号+空格作为分隔） #号 表示注释 注意属性名冒号后面与数据之间有一个空格 字面值、对象数据格式、数组数据格式 思考\n​\t现在我们已经知道了yaml具有严格的数据格式要求，并且已经可以正确的书写yaml文件了，那这些文件书写后其实是在定义一些数据。这些数据是给谁用的呢？大部分是SpringBoot框架内部使用，但是如果我们想配置一些数据自己使用，能不能用呢？答案是可以的，那如何读取yaml文件中的数据呢？咱们下一节再说。\nJC-2-4.yaml数据读取\r​\t对于yaml文件中的数据，其实你就可以想象成这就是一个小型的数据库，里面保存有若干数据，每个数据都有一个独立的名字，如果你想读取里面的数据，肯定是支持的，下面就介绍3种读取数据的方式。\n读取单一数据\r​\tyaml中保存的单个数据，可以使用Spring中的注解@Value读取单个数据，属性名引用方式：${一级属性名.二级属性名……}\n​\t记得使用@Value注解时，要将该注解写在某一个指定的Spring管控的bean的属性名上方，这样当bean进行初始化时候就可以读取到对应的单一数据了。\n总结\n使用@Value配合SpEL读取单个数据 如果数据存在多层级，依次书写层级名称即可 读取全部数据\r​\t读取单一数据可以解决读取数据的问题，但是如果定义的数据量过大，这么一个一个书写肯定会累死人的，SpringBoot提供了一个对象，能够把所有的数据都封装到这一个对象中，这个对象叫做Environment，使用自动装配注解可以将所有的yaml数据封装到这个对象中\n​\t数据封装到了Environment对象中，获取属性时，通过Environment的接口操作进行，具体方法是getProperties（String），参数填写属性名即可\n总结\n使用Environment对象封装全部配置信息 使用@Autowired自动装配数据到Environment对象中 读取对象数据\r​\t单一数据读取书写比较繁琐，全数据读取封装的太厉害了，每次拿数据还要一个一个的getProperties（）,总之用起来都不是很舒服。由于Java是一个面向对象的语言，很多情况下，我们会将一组数据封装成一个对象。SpringBoot也提供了可以将一组yaml对象数据封装一个Java对象的操作\n​\t首先定义一个对象，并将该对象纳入Spring管控的范围，也就是定义成一个bean，然后使用注解@ConfigurationProperties(这个注解依赖 setter, 对应的bean需要写setter方法)指定该对象加载哪一组yaml中配置的信息。\n​\t这个@ConfigurationProperties必须告诉他加载的数据前缀是什么，这样指定前缀下的所有属性就封装到这个对象中。记得数据属性名要与对象的变量名一一对应啊，不然没法封装。其实以后如果你要定义一组数据自己使用，就可以先写一个对象，然后定义好属性，下面到配置中根据这个格式书写即可。\n​\t温馨提示\n​\t细心的小伙伴会发现一个问题，自定义的这种数据在yaml文件中书写时没有弹出提示，咱们到原理篇再揭秘如何弹出提示。\n总结\n使用@ConfigurationProperties注解绑定配置信息到封装类中 封装类需要定义为Spring管理的bean，否则无法进行属性注入 yaml文件中的数据引用\r​\t如果你在书写yaml数据时，经常出现如下现象，比如很多个文件都具有相同的目录前缀\ncenter: dataDir: /usr/local/fire/data tmpDir: /usr/local/fire/tmp logDir: /usr/local/fire/log msgDir: /usr/local/fire/msgDir ​\t或者\ncenter: dataDir: D:/usr/local/fire/data tmpDir: D:/usr/local/fire/tmp logDir: D:/usr/local/fire/log msgDir: D:/usr/local/fire/msgDir ​\t这个时候你可以使用引用格式来定义数据，其实就是搞了个变量名，然后引用变量了，格式如下：\nbaseDir: /usr/local/fire center: dataDir: ${baseDir}/data tmpDir: ${baseDir}/tmp logDir: ${baseDir}/log msgDir: ${baseDir}/msgDir ​\t还有一个注意事项，在书写字符串时，如果需要使用转义字符，需要将数据字符串使用双引号包裹起来\nlesson: \u0026#34;Spring\\tboot\\nlesson\u0026#34; 总结\n在配置文件中可以使用${属性名}方式引用属性值 如果属性中出现特殊字符，可以使用双引号包裹起来作为字符解析 ​\t到这里有关yaml文件的基础使用就先告一段落，实用篇中再继续研究更深入的内容。\nJC-3.基于SpringBoot实现SSMP整合\r​\t重头戏来了，SpringBoot之所以好用，就是它能方便快捷的整合其他技术，这一部分咱们就来聊聊一些技术的整合方式，通过这一章的学习，大家能够感受到SpringBoot到底有多酷炫。这一章咱们学习如下技术的整合方式\n整合JUnit\n整合MyBatis\n整合MyBatis-Plus\n整合Druid\n上面这些技术都整合完毕后，我们做一个小案例，也算是学有所用吧。涉及的技术比较多，综合运用一下。\nJC-3-1.整合JUnit\r​\tSpringBoot技术的定位用于简化开发，再具体点是简化Spring程序的开发。所以在整合任意技术的时候，如果你想直观感触到简化的效果，你必须先知道使用非SpringBoot技术时对应的整合是如何做的，然后再看基于SpringBoot的整合是如何做的，才能比对出来简化在了哪里。\n​\t我们先来看一下不使用SpringBoot技术时，Spring整合JUnit的制作方式\n//加载spring整合junit专用的类运行器 @RunWith(SpringJUnit4ClassRunner.class) //指定对应的配置信息 @ContextConfiguration(classes = SpringConfig.class) public class AccountServiceTestCase { //注入你要测试的对象 @Autowired private AccountService accountService; @Test public void testGetById(){ //执行要测试的对象对应的方法 System.out.println(accountService.findById(2)); } } ​\t其中核心代码是前两个注解，第一个注解@RunWith是设置Spring专用的测试类运行器，简单说就是Spring程序执行程序有自己的一套独立的运行程序的方式，不能使用JUnit提供的类运行方式了，必须指定一下，但是格式是固定的，琢磨一下，每次都指定一样的东西，这个东西写起来没有技术含量啊，第二个注解@ContextConfiguration是用来设置Spring核心配置文件或配置类的，简单说就是加载Spring的环境你要告诉Spring具体的环境配置是在哪里写的，虽然每次加载的文件都有可能不同，但是仔细想想，如果文件名是固定的，这个貌似也是一个固定格式。既然有可能是固定格式，那就有可能每次都写一样的东西，也是一个没有技术含量的内容书写\n​\tSpringBoot就抓住上述两条没有技术含量的内容书写进行开发简化，能走默认值的走默认值，能不写的就不写，具体格式如下\n@SpringBootTest class Springboot04JunitApplicationTests { //注入你要测试的对象 @Autowired private BookDao bookDao; @Test void contextLoads() { //执行要测试的对象对应的方法 bookDao.save(); System.out.println(\u0026#34;two...\u0026#34;); } } ​\t看看这次简化成什么样了，一个注解就搞定了，而且还没有参数，再体会SpringBoot整合其他技术的优势在哪里，就两个字——简化。使用一个注解@SpringBootTest替换了前面两个注解。至于内部是怎么回事？和之前一样，只不过都走默认值。\n​\t这个时候有人就问了，你加载的配置类或者配置文件是哪一个？就是我们前面启动程序使用的引导类。如果想手工指定引导类有两种方式，第一种方式使用属性的形式进行，在注解@SpringBootTest中添加classes属性指定配置类\n@SpringBootTest(classes = Springboot04JunitApplication.class) class Springboot04JunitApplicationTests { //注入你要测试的对象 @Autowired private BookDao bookDao; @Test void contextLoads() { //执行要测试的对象对应的方法 bookDao.save(); System.out.println(\u0026#34;two...\u0026#34;); } } ​\t第二种方式回归原始配置方式，仍然使用@ContextConfiguration注解进行，效果是一样的\n@SpringBootTest @ContextConfiguration(classes = Springboot04JunitApplication.class) class Springboot04JunitApplicationTests { //注入你要测试的对象 @Autowired private BookDao bookDao; @Test void contextLoads() { //执行要测试的对象对应的方法 bookDao.save(); System.out.println(\u0026#34;two...\u0026#34;); } } 温馨提示\n​\t使用SpringBoot整合JUnit需要保障导入test对应的starter，由于初始化项目时此项是默认导入的，所以此处没有提及，其实和之前学习的内容一样，用什么技术导入对应的starter即可。\n总结\n导入测试对应的starter 测试类使用@SpringBootTest修饰 使用自动装配的形式添加要测试的对象 测试类如果存在于引导类所在包或子包中无需指定引导类 测试类如果不存在于引导类所在的包或子包中需要通过classes属性指定引导类 JC-3-2.整合MyBatis\r​\t整合完JUnit下面再来说一下整合MyBatis，这个技术是大部分公司都要使用的技术，务必掌握。如果对Spring整合MyBatis不熟悉的小伙伴好好复习一下，下面列举出原始整合的全部内容，以配置类的形式为例进行\n导入坐标，MyBatis坐标不能少，Spring整合MyBatis还有自己专用的坐标，此外Spring进行数据库操作的jdbc坐标是必须的，剩下还有mysql驱动坐标，本例中使用了Druid数据源，这个倒是可以不要\n\u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.alibaba\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;druid\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.1.16\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.mybatis\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mybatis\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.5.6\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;mysql\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mysql-connector-java\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;5.1.47\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!--1.导入mybatis与spring整合的jar包--\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.mybatis\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mybatis-spring\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.3.0\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!--导入spring操作数据库必选的包--\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-jdbc\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;5.2.10.RELEASE\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; Spring核心配置\n@Configuration @ComponentScan(\u0026#34;com.itheima\u0026#34;) @PropertySource(\u0026#34;jdbc.properties\u0026#34;) public class SpringConfig { } MyBatis要交给Spring接管的bean\n//定义mybatis专用的配置类 @Configuration public class MyBatisConfig { // 定义创建SqlSessionFactory对应的bean @Bean public SqlSessionFactoryBean sqlSessionFactory(DataSource dataSource){ //SqlSessionFactoryBean是由mybatis-spring包提供的，专用于整合用的对象 SqlSessionFactoryBean sfb = new SqlSessionFactoryBean(); //设置数据源替代原始配置中的environments的配置 sfb.setDataSource(dataSource); //设置类型别名替代原始配置中的typeAliases的配置 sfb.setTypeAliasesPackage(\u0026#34;com.itheima.domain\u0026#34;); return sfb; } // 定义加载所有的映射配置 @Bean public MapperScannerConfigurer mapperScannerConfigurer(){ MapperScannerConfigurer msc = new MapperScannerConfigurer(); msc.setBasePackage(\u0026#34;com.itheima.dao\u0026#34;); return msc; } } 数据源对应的bean，此处使用Druid数据源\n@Configuration public class JdbcConfig { @Value(\u0026#34;${jdbc.driver}\u0026#34;) private String driver; @Value(\u0026#34;${jdbc.url}\u0026#34;) private String url; @Value(\u0026#34;${jdbc.username}\u0026#34;) private String userName; @Value(\u0026#34;${jdbc.password}\u0026#34;) private String password; @Bean(\u0026#34;dataSource\u0026#34;) public DataSource dataSource(){ DruidDataSource ds = new DruidDataSource(); ds.setDriverClassName(driver); ds.setUrl(url); ds.setUsername(userName); ds.setPassword(password); return ds; } } 数据库连接信息（properties格式）\njdbc.driver=com.mysql.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/spring_db?useSSL=false jdbc.username=root jdbc.password=root 上述格式基本上是最简格式了，要写的东西还真不少。下面看看SpringBoot整合MyBaits格式\n步骤①：创建模块\n步骤②：勾选要使用的技术，MyBatis，由于要操作数据库，还要勾选对应数据库\n​\t或者手工导入对应技术的starter，和对应数据库的坐标\n\u0026lt;dependencies\u0026gt; \u0026lt;!--1.导入对应的starter--\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.mybatis.spring.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mybatis-spring-boot-starter\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.2.0\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;mysql\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mysql-connector-java\u0026lt;/artifactId\u0026gt; \u0026lt;scope\u0026gt;runtime\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; 步骤③：配置数据源相关信息，没有这个信息你连接哪个数据库都不知道\n#2.配置相关信息 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/ssm_db username: root password: root ​\t结束了，就这么多，没了。有人就很纳闷，这就结束了？对，这就结束了，SpringBoot把配置中所有可能出现的通用配置都简化了。下面写一个MyBatis程序运行需要的Dao（或者Mapper）就可以运行了\n实体类\npublic class Book { private Integer id; private String type; private String name; private String description; } 映射接口（Dao）\n@Mapper public interface BookDao { @Select(\u0026#34;select * from tbl_book where id = #{id}\u0026#34;) public Book getById(Integer id); } 测试类\n@SpringBootTest class Springboot05MybatisApplicationTests { @Autowired private BookDao bookDao; @Test void contextLoads() { System.out.println(bookDao.getById(1)); } } ​\t完美，开发从此变的就这么简单。再体会一下SpringBoot如何进行第三方技术整合的，是不是很优秀？具体内部的原理到原理篇再展开讲解\n​\t注意：当前使用的SpringBoot版本是2.5.4，对应的坐标设置中Mysql驱动使用的是8x版本。使用SpringBoot2.4.3（不含）之前版本会出现一个小BUG，就是MySQL驱动升级到8以后要求强制配置时区，如果不设置会出问题。解决方案很简单，驱动url上面添加上对应设置就行了\n#2.配置相关信息 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/ssm_db?serverTimezone=UTC username: root password: root ​\t这里设置的UTC是全球标准时间，你也可以理解为是英国时间，中国处在东八区，需要在这个基础上加上8小时，这样才能和中国地区的时间对应的，也可以修改配置为Asia/Shanghai，同样可以解决这个问题。\n#2.配置相关信息 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/ssm_db?serverTimezone=Asia/Shanghai username: root password: root ​\t如果不想每次都设置这个东西，也可以去修改mysql中的配置文件mysql.ini，在mysqld项中添加default-time-zone=+8:00也可以解决这个问题。其实方式方法很多，这里就说这么多吧。\n​\t此外在运行程序时还会给出一个提示，说数据库驱动过时的警告，根据提示修改配置即可，弃用com.mysql.jdbc.Driver，换用com.mysql.cj.jdbc.Driver。前面的例子中已经更换了驱动了，在此说明一下。\nLoading class `com.mysql.jdbc.Driver\u0026#39;. This is deprecated. The new driver class is `com.mysql.cj.jdbc.Driver\u0026#39;. The driver is automatically registered via the SPI and manual loading of the driver class is generally unnecessary. 总结\n整合操作需要勾选MyBatis技术，也就是导入MyBatis对应的starter\n数据库连接相关信息转换成配置\n数据库SQL映射需要添加@Mapper被容器识别到\nMySQL 8.X驱动强制要求设置时区\n修改url，添加serverTimezone设定 修改MySQL数据库配置 驱动类过时，提醒更换为com.mysql.cj.jdbc.Driver\nJC-3-3.整合MyBatis-Plus\r​\t做完了两种技术的整合了，各位小伙伴要学会总结，我们做这个整合究竟哪些是核心？总结下来就两句话\n导入对应技术的starter坐标\n根据对应技术的要求做配置\n虽然看起来有点虚，但是确实是这个理儿，下面趁热打铁，再换一个技术，看看是不是上面这两步。\n​\t接下来在MyBatis的基础上再升级一下，整合MyBaitsPlus（简称MP），国人开发的技术，符合中国人开发习惯，谁用谁知道。来吧，一起做整合\n步骤①：导入对应的starter\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.baomidou\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mybatis-plus-boot-starter\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.4.3\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; ​\t关于这个坐标，此处要说明一点，之前我们看的starter都是spring-boot-starter-？？？，也就是说都是下面的格式\nSpring-boot-start-*** ​\t而MyBatis与MyBatisPlus这两个坐标的名字书写比较特殊，是第三方技术名称在前，boot和starter在后。此处简单提一下命名规范，后期原理篇会再详细讲解\nstarter所属 命名规则 示例 官方提供 spring-boot-starter-技术名称 spring-boot-starter-web spring-boot-starter-test 第三方提供 第三方技术名称-spring-boot-starter mybatis-spring-boot-starterdruid-spring-boot-starter 第三方提供 第三方技术名称-boot-starter（第三方技术名称过长，简化命名） mybatis-plus-boot-starter 温馨提示\n​\t有些小伙伴在创建项目时想通过勾选的形式找到这个名字，别翻了，没有。截止目前，SpringBoot官网还未收录此坐标，而我们Idea创建模块时读取的是SpringBoot官网的Spring Initializr，所以也没有。如果换用阿里云的url创建项目可以找到对应的坐标。\n步骤②：配置数据源相关信息\n#2.配置相关信息 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/ssm_db username: root password: root ​\t没了，就这么多，剩下的就是写MyBaitsPlus的程序了\n映射接口（Dao）\n@Mapper public interface BookDao extends BaseMapper\u0026lt;Book\u0026gt; { } ​\t核心在于Dao接口继承了一个BaseMapper的接口，这个接口中帮助开发者预定了若干个常用的API接口，简化了通用API接口的开发工作。\n​\t下面就可以写一个测试类进行测试了，此处省略。\n温馨提示\n​\t目前数据库的表名定义规则是tbl_模块名称，为了能和实体类相对应，需要做一个配置，相关知识各位小伙伴可以到MyBatisPlus课程中去学习，此处仅给出解决方案。配置application.yml文件，添加如下配置即可，设置所有表名的通用前缀名\nmybatis-plus: global-config: db-config: table-prefix: tbl_\t#设置所有表的通用前缀名称为tbl_ 总结\n手工添加MyBatis-Plus对应的starter 数据层接口使用BaseMapper简化开发 需要使用的第三方技术无法通过勾选确定时，需要手工添加坐标 JC-3-4.整合Druid\r​\t使用SpringBoot整合了3个技术了，发现套路基本相同，导入对应的starter，然后做配置，各位小伙伴需要一直强化这套思想。下面再整合一个技术，继续深入强化此思想。\n​\t前面整合MyBatis和MyBatisPlus的时候，使用的数据源对象都是SpringBoot默认的数据源对象，下面我们手工控制一下，自己指定了一个数据源对象，Druid。\n​\t在没有指定数据源时，我们的配置如下：\n#2.配置相关信息 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/ssm_db?serverTimezone=Asia/Shanghai username: root password: root ​\t此时虽然没有指定数据源，但是根据SpringBoot的德行，肯定帮我们选了一个它认为最好的数据源对象，这就是HiKari。通过启动日志可以查看到对应的身影。\n2021-11-29 09:39:15.202 INFO 12260 --- [ main] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Starting... 2021-11-29 09:39:15.208 WARN 12260 --- [ main] com.zaxxer.hikari.util.DriverDataSource : Registered driver with driverClassName=com.mysql.jdbc.Driver was not found, trying direct instantiation. 2021-11-29 09:39:15.551 INFO 12260 --- [ main] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Start completed. ​\t上述信息中每一行都有HiKari的身影，如果需要更换数据源，其实只需要两步即可。\n导入对应的技术坐标\n配置使用指定的数据源类型\n下面就切换一下数据源对象\n步骤①：导入对应的坐标（注意，是坐标，此处不是starter）\n\u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.alibaba\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;druid\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.1.16\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; 步骤②：修改配置，在数据源配置中有一个type属性，专用于指定数据源类型\nspring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/ssm_db?serverTimezone=UTC username: root password: root type: com.alibaba.druid.pool.DruidDataSource ​\t这里其实要提出一个问题的，目前的数据源配置格式是一个通用格式，不管你换什么数据源都可以用这种形式进行配置。但是新的问题又来了，如果对数据源进行个性化的配置，例如配置数据源对应的连接数量，这个时候就有新的问题了。每个数据源技术对应的配置名称都一样吗？肯定不是啊，各个厂商不可能提前商量好都写一样的名字啊，怎么办？就要使用专用的配置格式了。这个时候上面这种通用格式就不能使用了，怎么办？还能怎么办？按照SpringBoot整合其他技术的通用规则来套啊，导入对应的starter，进行相应的配置即可。\n步骤①：导入对应的starter\n\u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.alibaba\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;druid-spring-boot-starter\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.2.6\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; 步骤②：修改配置\nspring: datasource: druid: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/ssm_db?serverTimezone=UTC username: root password: root ​\t注意观察，配置项中，在datasource下面并不是直接配置url这些属性的，而是先配置了一个druid节点，然后再配置的url这些东西。言外之意，url这个属性是druid下面的属性，那你能想到什么？除了这4个常规配置外，还有druid专用的其他配置。通过提示功能可以打开druid相关的配置查阅\n​\t与druid相关的配置超过200条以上，这就告诉你，如果想做druid相关的配置，使用这种格式就可以了，这里就不展开描述了，太多了。\n​\t这是我们做的第4个技术的整合方案，还是那两句话：导入对应starter，使用对应配置。没了，SpringBoot整合其他技术就这么简单粗暴。\n总结\n整合Druid需要导入Druid对应的starter 根据Druid提供的配置方式进行配置 整合第三方技术通用方式 导入对应的starter 根据提供的配置格式，配置非默认值对应的配置项 JC-3-5.SSMP整合综合案例\r​\tSpringBoot能够整合的技术太多太多了，对于初学者来说慢慢来，一点点掌握。前面咱们做了4个整合了，下面就通过一个稍微综合一点的案例，将所有知识贯穿起来，同时做一个小功能，体会一下。不过有言在先，这个案例制作的时候，你可能会有这种感觉，说好的SpringBoot整合其他技术的案例，为什么感觉SpringBoot整合其他技术的身影不多呢？因为这东西书写太简单了，简单到瞬间写完，大量的时间做的不是这些整合工作。\n​\t先看一下这个案例的最终效果\n主页面\n添加\n删除\n修改\n分页\n条件查询\n​\t整体案例中需要采用的技术如下，先了解一下，做到哪一个说哪一个\n实体类开发————使用Lombok快速制作实体类 Dao开发————整合MyBatisPlus，制作数据层测试 Service开发————基于MyBatisPlus进行增量开发，制作业务层测试类 Controller开发————基于Restful开发，使用PostMan测试接口功能 Controller开发————前后端开发协议制作 页面开发————基于VUE+ElementUI制作，前后端联调，页面数据处理，页面消息处理 列表 新增 修改 删除 分页 查询 项目异常处理 按条件查询————页面功能调整、Controller修正功能、Service修正功能 ​\t可以看的出来，东西还是很多的，希望通过这个案例，各位小伙伴能够完成基础开发的技能训练。整体开发过程采用做一层测一层的形式进行，过程完整，战线较长，希望各位能跟紧进度，完成这个小案例的制作。\n0.模块创建\r​\t对于这个案例如果按照企业开发的形式进行应该制作后台微服务，前后端分离的开发。\n​\t我知道这个对初学的小伙伴要求太高了，咱们简化一下。后台做单体服务器，前端不使用前后端分离的制作了。\n​\t一个服务器即充当后台服务调用，又负责前端页面展示，降低学习的门槛。\n​\t下面我们创建一个新的模块，加载要使用的技术对应的starter，修改配置文件格式为yml格式，并把web访问端口先设置成80。\npom.xml\n\u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-web\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-test\u0026lt;/artifactId\u0026gt; \u0026lt;scope\u0026gt;test\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; application.yml\nserver: port: 80 1.实体类开发\r​\t本案例对应的模块表结构如下：\n-- ---------------------------- -- Table structure for tbl_book -- ---------------------------- DROP TABLE IF EXISTS `tbl_book`; CREATE TABLE `tbl_book` ( `id` int(11) NOT NULL AUTO_INCREMENT, `type` varchar(20) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL, `name` varchar(50) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL, `description` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL, PRIMARY KEY (`id`) USING BTREE ) ENGINE = InnoDB AUTO_INCREMENT = 51 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic; -- ---------------------------- -- Records of tbl_book -- ---------------------------- INSERT INTO `tbl_book` VALUES (1, \u0026#39;计算机理论\u0026#39;, \u0026#39;Spring实战 第5版\u0026#39;, \u0026#39;Spring入门经典教程，深入理解Spring原理技术内幕\u0026#39;); INSERT INTO `tbl_book` VALUES (2, \u0026#39;计算机理论\u0026#39;, \u0026#39;Spring 5核心原理与30个类手写实战\u0026#39;, \u0026#39;十年沉淀之作，手写Spring精华思想\u0026#39;); INSERT INTO `tbl_book` VALUES (3, \u0026#39;计算机理论\u0026#39;, \u0026#39;Spring 5 设计模式\u0026#39;, \u0026#39;深入Spring源码剖析Spring源码中蕴含的10大设计模式\u0026#39;); INSERT INTO `tbl_book` VALUES (4, \u0026#39;计算机理论\u0026#39;, \u0026#39;Spring MVC+MyBatis开发从入门到项目实战\u0026#39;, \u0026#39;全方位解析面向Web应用的轻量级框架，带你成为Spring MVC开发高手\u0026#39;); INSERT INTO `tbl_book` VALUES (5, \u0026#39;计算机理论\u0026#39;, \u0026#39;轻量级Java Web企业应用实战\u0026#39;, \u0026#39;源码级剖析Spring框架，适合已掌握Java基础的读者\u0026#39;); INSERT INTO `tbl_book` VALUES (6, \u0026#39;计算机理论\u0026#39;, \u0026#39;Java核心技术 卷I 基础知识（原书第11版）\u0026#39;, \u0026#39;Core Java 第11版，Jolt大奖获奖作品，针对Java SE9、10、11全面更新\u0026#39;); INSERT INTO `tbl_book` VALUES (7, \u0026#39;计算机理论\u0026#39;, \u0026#39;深入理解Java虚拟机\u0026#39;, \u0026#39;5个维度全面剖析JVM，大厂面试知识点全覆盖\u0026#39;); INSERT INTO `tbl_book` VALUES (8, \u0026#39;计算机理论\u0026#39;, \u0026#39;Java编程思想（第4版）\u0026#39;, \u0026#39;Java学习必读经典,殿堂级著作！赢得了全球程序员的广泛赞誉\u0026#39;); INSERT INTO `tbl_book` VALUES (9, \u0026#39;计算机理论\u0026#39;, \u0026#39;零基础学Java（全彩版）\u0026#39;, \u0026#39;零基础自学编程的入门图书，由浅入深，详解Java语言的编程思想和核心技术\u0026#39;); INSERT INTO `tbl_book` VALUES (10, \u0026#39;市场营销\u0026#39;, \u0026#39;直播就该这么做：主播高效沟通实战指南\u0026#39;, \u0026#39;李子柒、李佳琦、薇娅成长为网红的秘密都在书中\u0026#39;); INSERT INTO `tbl_book` VALUES (11, \u0026#39;市场营销\u0026#39;, \u0026#39;直播销讲实战一本通\u0026#39;, \u0026#39;和秋叶一起学系列网络营销书籍\u0026#39;); INSERT INTO `tbl_book` VALUES (12, \u0026#39;市场营销\u0026#39;, \u0026#39;直播带货：淘宝、天猫直播从新手到高手\u0026#39;, \u0026#39;一本教你如何玩转直播的书，10堂课轻松实现带货月入3W+\u0026#39;); ​\t根据上述表结构，制作对应的实体类\n实体类\npublic class Book { private Integer id; private String type; private String name; private String description; } ​\t实体类的开发可以自动通过工具手工生成get/set方法，然后覆盖toString()方法，方便调试，等等。不过这一套操作书写很繁琐，有对应的工具可以帮助我们简化开发，介绍一个小工具，lombok。\n​\tLombok，一个Java类库，提供了一组注解，简化POJO实体类开发，SpringBoot目前默认集成了lombok技术，并提供了对应的版本控制，所以只需要提供对应的坐标即可，在pom.xml中添加lombok的坐标。\n\u0026lt;dependencies\u0026gt; \u0026lt;!--lombok--\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.projectlombok\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;lombok\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; ​\t使用lombok可以通过一个注解@Data完成一个实体类对应的getter，setter，toString，equals，hashCode等操作的快速添加\nimport lombok.Data; @Data public class Book { private Integer id; private String type; private String name; private String description; } ​\t到这里实体类就做好了，是不是比不使用lombok简化好多，这种工具在Java开发中还有N多，后面遇到了能用的实用开发技术时，在不增加各位小伙伴大量的学习时间的情况下，尽量多给大家介绍一些。\n总结\n实体类制作 使用lombok简化开发 导入lombok无需指定版本，由SpringBoot提供版本 @Data注解 2.数据层开发——基础CRUD\r​\t数据层开发本次使用MyBatisPlus技术，数据源使用前面学习的Druid，学都学了都用上。\n步骤①：导入MyBatisPlus与Druid对应的starter，当然mysql的驱动不能少\n\u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.baomidou\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mybatis-plus-boot-starter\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.4.3\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.alibaba\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;druid-spring-boot-starter\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.2.6\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;mysql\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mysql-connector-java\u0026lt;/artifactId\u0026gt; \u0026lt;scope\u0026gt;runtime\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; 步骤②：配置数据库连接相关的数据源配置\nserver: port: 80 spring: datasource: druid: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/ssm_db?serverTimezone=UTC username: root password: root 步骤③：使用MyBatisPlus的标准通用接口BaseMapper加速开发，别忘了@Mapper和泛型的指定\n@Mapper public interface BookDao extends BaseMapper\u0026lt;Book\u0026gt; { } 步骤④：制作测试类测试结果，这个测试类制作是个好习惯，不过在企业开发中往往都为加速开发跳过此步，且行且珍惜吧\npackage com.itheima.dao; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.itheima.domain.Book; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @SpringBootTest public class BookDaoTestCase { @Autowired private BookDao bookDao; @Test void testGetById(){ System.out.println(bookDao.selectById(1)); } @Test void testSave(){ Book book = new Book(); book.setType(\u0026#34;测试数据123\u0026#34;); book.setName(\u0026#34;测试数据123\u0026#34;); book.setDescription(\u0026#34;测试数据123\u0026#34;); bookDao.insert(book); } @Test void testUpdate(){ Book book = new Book(); book.setId(17); book.setType(\u0026#34;测试数据abcdefg\u0026#34;); book.setName(\u0026#34;测试数据123\u0026#34;); book.setDescription(\u0026#34;测试数据123\u0026#34;); bookDao.updateById(book); } @Test void testDelete(){ bookDao.deleteById(16); } @Test void testGetAll(){ bookDao.selectList(null); } } 温馨提示\n​\tMyBatisPlus技术默认的主键生成策略为雪花算法，生成的主键ID长度较大，和目前的数据库设定规则不相符，需要配置一下使MyBatisPlus使用数据库的主键生成策略，方式嘛还是老一套，做配置。在application.yml中添加对应配置即可，具体如下\nserver: port: 80 spring: datasource: druid: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/ssm_db?serverTimezone=UTC username: root password: root mybatis-plus: global-config: db-config: table-prefix: tbl_\t#设置表名通用前缀 id-type: auto\t#设置主键id字段的生成策略为参照数据库设定的策略，当前数据库设置id生成策略为自增 查看MyBatisPlus运行日志\r​\t在进行数据层测试的时候，因为基础的CRUD操作均由MyBatisPlus给我们提供了，所以就出现了一个局面，开发者不需要书写SQL语句了，这样程序运行的时候总有一种感觉，一切的一切都是黑盒的，作为开发者我们啥也不知道就完了。如果程序正常运行还好，如果报错了，这个时候就很崩溃，你甚至都不知道从何下手，因为传递参数、封装SQL语句这些操作完全不是你开发出来的，所以查看执行期运行的SQL语句就成为当务之急。\n​\tSpringBoot整合MyBatisPlus的时候充分考虑到了这点，通过配置的形式就可以查阅执行期SQL语句，配置如下\nmybatis-plus: global-config: db-config: table-prefix: tbl_ id-type: auto configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl ​\t再来看运行结果，此时就显示了运行期执行SQL的情况。\nCreating a new SqlSession SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@2c9a6717] was not registered for synchronization because synchronization is not active JDBC Connection [com.mysql.cj.jdbc.ConnectionImpl@6ca30b8a] will not be managed by Spring ==\u0026gt; Preparing: SELECT id,type,name,description FROM tbl_book ==\u0026gt; Parameters: \u0026lt;== Columns: id, type, name, description \u0026lt;== Row: 1, 计算机理论, Spring实战 第5版, Spring入门经典教程，深入理解Spring原理技术内幕 \u0026lt;== Row: 2, 计算机理论, Spring 5核心原理与30个类手写实战, 十年沉淀之作，手写Spring精华思想 \u0026lt;== Row: 3, 计算机理论, Spring 5 设计模式, 深入Spring源码剖析Spring源码中蕴含的10大设计模式 \u0026lt;== Row: 4, 计算机理论, Spring MVC+MyBatis开发从入门到项目实战, 全方位解析面向Web应用的轻量级框架，带你成为Spring MVC开发高手 \u0026lt;== Row: 5, 计算机理论, 轻量级Java Web企业应用实战, 源码级剖析Spring框架，适合已掌握Java基础的读者 \u0026lt;== Row: 6, 计算机理论, Java核心技术 卷I 基础知识（原书第11版）, Core Java 第11版，Jolt大奖获奖作品，针对Java SE9、10、11全面更新 \u0026lt;== Row: 7, 计算机理论, 深入理解Java虚拟机, 5个维度全面剖析JVM，大厂面试知识点全覆盖 \u0026lt;== Row: 8, 计算机理论, Java编程思想（第4版）, Java学习必读经典,殿堂级著作！赢得了全球程序员的广泛赞誉 \u0026lt;== Row: 9, 计算机理论, 零基础学Java（全彩版）, 零基础自学编程的入门图书，由浅入深，详解Java语言的编程思想和核心技术 \u0026lt;== Row: 10, 市场营销, 直播就该这么做：主播高效沟通实战指南, 李子柒、李佳琦、薇娅成长为网红的秘密都在书中 \u0026lt;== Row: 11, 市场营销, 直播销讲实战一本通, 和秋叶一起学系列网络营销书籍 \u0026lt;== Row: 12, 市场营销, 直播带货：淘宝、天猫直播从新手到高手, 一本教你如何玩转直播的书，10堂课轻松实现带货月入3W+ \u0026lt;== Row: 13, 测试类型, 测试数据, 测试描述数据 \u0026lt;== Row: 14, 测试数据update, 测试数据update, 测试数据update \u0026lt;== Row: 15, -----------------, 测试数据123, 测试数据123 \u0026lt;== Total: 15 ​\t其中清晰的标注了当前执行的SQL语句是什么，携带了什么参数，对应的执行结果是什么，所有信息应有尽有。\n​\t此处设置的是日志的显示形式，当前配置的是控制台输出，当然还可以由更多的选择，根据需求切换即可\n总结\n手工导入starter坐标（2个），mysql驱动（1个）\n配置数据源与MyBatisPlus对应的配置\n开发Dao接口（继承BaseMapper）\n制作测试类测试Dao功能是否有效\n使用配置方式开启日志，设置日志输出方式为标准输出即可查阅SQL执行日志\n3.数据层开发——分页功能制作\r​\t前面仅仅是使用了MyBatisPlus提供的基础CRUD功能，实际上MyBatisPlus给我们提供了几乎所有的基础操作，这一节说一下如何实现数据库端的分页操作。\n​\tMyBatisPlus提供的分页操作API如下：\n@Test void testGetPage(){ IPage page = new Page(2,5); bookDao.selectPage(page, null); System.out.println(page.getCurrent()); System.out.println(page.getSize()); System.out.println(page.getTotal()); System.out.println(page.getPages()); System.out.println(page.getRecords()); } ​\t其中selectPage方法需要传入一个封装分页数据的对象，可以通过new的形式创建这个对象，当然这个对象也是MyBatisPlus提供的，别选错包了。创建此对象时需要指定两个分页的基本数据\n当前显示第几页 每页显示几条数据 ​\t可以通过创建Page对象时利用构造方法初始化这两个数据。\nIPage page = new Page(2,5); ​\t将该对象传入到查询方法selectPage后，可以得到查询结果，但是我们会发现当前操作查询结果返回值仍然是一个IPage对象，这又是怎么回事？\nIPage page = bookDao.selectPage(page, null); ​\t原来这个IPage对象中封装了若干个数据，而查询的结果作为IPage对象封装的一个数据存在的，可以理解为查询结果得到后，又塞到了这个IPage对象中，其实还是为了高度的封装，一个IPage描述了分页所有的信息。下面5个操作就是IPage对象中封装的所有信息了。\n@Test void testGetPage(){ IPage page = new Page(2,5); bookDao.selectPage(page, null); System.out.println(page.getCurrent());\t//当前页码值 System.out.println(page.getSize());\t//每页显示数 System.out.println(page.getTotal());\t//数据总量 System.out.println(page.getPages());\t//总页数 System.out.println(page.getRecords());\t//详细数据 } ​\t到这里就知道这些数据如何获取了，但是当你去执行这个操作时，你会发现并不像我们分析的这样，实际上这个分页功能当前是无效的。为什么这样呢？这个要源于MyBatisPlus的内部机制。\n​\t对于MySQL的分页操作使用limit关键字进行，而并不是所有的数据库都使用limit关键字实现的，这个时候MyBatisPlus为了制作的兼容性强，将分页操作设置为基础查询操作的升级版，你可以理解为IPhone6与IPhone6S-PLUS的关系。\n​\t基础操作中有查询全部的功能，而在这个基础上只需要升级一下（PLUS）就可以得到分页操作。所以MyBatisPlus将分页操作做成了一个开关，你用分页功能就把开关开启，不用就不需要开启这个开关。而我们现在没有开启这个开关，所以分页操作是没有的。这个开关是通过MyBatisPlus的拦截器的形式存在的，其中的原理这里不分析了，有兴趣的小伙伴可以学习MyBatisPlus这门课程进行详细解读。具体设置方式如下：\n定义MyBatisPlus拦截器并将其设置为Spring管控的bean\n@Configuration public class MPConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor(){ MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor()); return interceptor; } } ​\t上述代码第一行是创建MyBatisPlus的拦截器栈，这个时候拦截器栈中没有具体的拦截器，第二行是初始化了分页拦截器，并添加到拦截器栈中。如果后期开发其他功能，需要添加全新的拦截器，按照第二行的格式继续add进去新的拦截器就可以了。\n总结\n使用IPage封装分页数据 分页操作依赖MyBatisPlus分页拦截器实现功能 借助MyBatisPlus日志查阅执行SQL语句 4.数据层开发——条件查询功能制作\r​\t除了分页功能，MyBatisPlus还提供有强大的条件查询功能。以往我们写条件查询要自己动态拼写复杂的SQL语句，现在简单了，MyBatisPlus将这些操作都制作成API接口，调用一个又一个的方法就可以实现各种条件的拼装。这里给大家普及一下基本格式，详细的操作还是到MyBatisPlus的课程中查阅吧。\n​\t下面的操作就是执行一个模糊匹配对应的操作，由like条件书写变为了like方法的调用。\n@Test void testGetBy(){ QueryWrapper\u0026lt;Book\u0026gt; qw = new QueryWrapper\u0026lt;\u0026gt;(); qw.like(\u0026#34;name\u0026#34;,\u0026#34;Spring\u0026#34;); bookDao.selectList(qw); } ​\t其中第一句QueryWrapper对象是一个用于封装查询条件的对象，该对象可以动态使用API调用的方法添加条件，最终转化成对应的SQL语句。第二句就是一个条件了，需要什么条件，使用QueryWapper对象直接调用对应操作即可。比如做大于小于关系，就可以使用lt或gt方法，等于使用eq方法，等等，此处不做更多的解释了。\n​\t这组API使用还是比较简单的，但是关于属性字段名的书写存在着安全隐患，比如查询字段name，当前是以字符串的形态书写的，万一写错，编译器还没有办法发现，只能将问题抛到运行器通过异常堆栈告诉开发者，不太友好。\n​\tMyBatisPlus针对字段检查进行了功能升级，全面支持Lambda表达式，就有了下面这组API。由QueryWrapper对象升级为LambdaQueryWrapper对象，这下就避免了上述问题的出现。\n@Test void testGetBy2(){ String name = \u0026#34;1\u0026#34;; LambdaQueryWrapper\u0026lt;Book\u0026gt; lqw = new LambdaQueryWrapper\u0026lt;Book\u0026gt;(); lqw.like(Book::getName,name); bookDao.selectList(lqw); } ​\t为了便于开发者动态拼写SQL，防止将null数据作为条件使用，MyBatisPlus还提供了动态拼装SQL的快捷书写方式。\n@Test void testGetBy2(){ String name = \u0026#34;1\u0026#34;; LambdaQueryWrapper\u0026lt;Book\u0026gt; lqw = new LambdaQueryWrapper\u0026lt;Book\u0026gt;(); //if(name != null) lqw.like(Book::getName,name);\t//方式一：JAVA代码控制 lqw.like(name != null,Book::getName,name);\t//方式二：API接口提供控制开关 bookDao.selectList(lqw); } ​\t其实就是个格式，没有区别。关于MyBatisPlus的基础操作就说到这里吧，如果这一块知识不太熟悉的小伙伴建议还是完整的学习一下MyBatisPlus的知识吧，这里只是蜻蜓点水的用了几个操作而已。\n总结\n使用QueryWrapper对象封装查询条件\n推荐使用LambdaQueryWrapper对象\n所有查询操作封装成方法调用\n查询条件支持动态条件拼装\n5.业务层开发\r​\t数据层开发告一段落，下面进行业务层开发，其实标准业务层开发很多初学者认为就是调用数据层，怎么说呢？这个理解是没有大问题的，更精准的说法应该是组织业务逻辑功能，并根据业务需求，对数据持久层发起调用。有什么差别呢？目标是为了组织出符合需求的业务逻辑功能，至于调不调用数据层还真不好说，有需求就调用，没有需求就不调用。\n​\t一个常识性的知识普及一下，业务层的方法名定义一定要与业务有关，例如登录操作\nlogin(String username,String password); ​\t而数据层的方法名定义一定与业务无关，是一定，不是可能，也不是有可能，例如根据用户名密码查询\nselectByUserNameAndPassword(String username,String password); ​\t我们在开发的时候是可以根据完成的工作不同划分成不同职能的开发团队的。比如一个哥们制作数据层，他就可以不知道业务是什么样子，拿到的需求文档要求可能是这样的\n接口：传入用户名与密码字段，查询出对应结果，结果是单条数据 接口：传入ID字段，查询出对应结果，结果是单条数据 接口：传入离职字段，查询出对应结果，结果是多条数据 ​\t但是进行业务功能开发的哥们，拿到的需求文档要求差别就很大\n接口：传入用户名与密码字段，对用户名字段做长度校验，4-15位，对密码字段做长度校验，8到24位，对密码字段做特殊字符校验，不允许存在空格，查询结果为对象。如果为null，返回BusinessException，封装消息码INFO_LOGON_USERNAME_PASSWORD_ERROR ​\t你比较一下，能是一回事吗？差别太大了，所以说业务层方法定义与数据层方法定义差异化很大，只不过有些入门级的开发者手懒或者没有使用过公司相关的ISO标准化文档而已。\n​\t多余的话不说了，咱们做案例就简单制作了，业务层接口定义如下：\npublic interface BookService { Boolean save(Book book); Boolean update(Book book); Boolean delete(Integer id); Book getById(Integer id); List\u0026lt;Book\u0026gt; getAll(); IPage\u0026lt;Book\u0026gt; getPage(int currentPage,int pageSize); } ​\t业务层实现类如下，转调数据层即可：\n@Service public class BookServiceImpl implements BookService { @Autowired private BookDao bookDao; @Override public Boolean save(Book book) { return bookDao.insert(book) \u0026gt; 0; } @Override public Boolean update(Book book) { return bookDao.updateById(book) \u0026gt; 0; } @Override public Boolean delete(Integer id) { return bookDao.deleteById(id) \u0026gt; 0; } @Override public Book getById(Integer id) { return bookDao.selectById(id); } @Override public List\u0026lt;Book\u0026gt; getAll() { return bookDao.selectList(null); } @Override public IPage\u0026lt;Book\u0026gt; getPage(int currentPage, int pageSize) { IPage page = new Page(currentPage,pageSize); bookDao.selectPage(page,null); return page; } } ​\t别忘了对业务层接口进行测试，测试类如下：\n@SpringBootTest public class BookServiceTest { @Autowired private IBookService bookService; @Test void testGetById(){ System.out.println(bookService.getById(4)); } @Test void testSave(){ Book book = new Book(); book.setType(\u0026#34;测试数据123\u0026#34;); book.setName(\u0026#34;测试数据123\u0026#34;); book.setDescription(\u0026#34;测试数据123\u0026#34;); bookService.save(book); } @Test void testUpdate(){ Book book = new Book(); book.setId(17); book.setType(\u0026#34;-----------------\u0026#34;); book.setName(\u0026#34;测试数据123\u0026#34;); book.setDescription(\u0026#34;测试数据123\u0026#34;); bookService.updateById(book); } @Test void testDelete(){ bookService.removeById(18); } @Test void testGetAll(){ bookService.list(); } @Test void testGetPage(){ IPage\u0026lt;Book\u0026gt; page = new Page\u0026lt;Book\u0026gt;(2,5); bookService.page(page); System.out.println(page.getCurrent()); System.out.println(page.getSize()); System.out.println(page.getTotal()); System.out.println(page.getPages()); System.out.println(page.getRecords()); } } 总结\nService接口名称定义成业务名称，并与Dao接口名称进行区分 制作测试类测试Service功能是否有效 业务层快速开发\r​\t其实MyBatisPlus技术不仅提供了数据层快速开发方案，业务层MyBatisPlus也给了一个通用接口，个人观点不推荐使用，凑合能用吧，其实就是一个封装+继承的思想，代码给出，实际开发慎用。\n​\t业务层接口快速开发\npublic interface IBookService extends IService\u0026lt;Book\u0026gt; { //添加非通用操作API接口 } ​\t业务层接口实现类快速开发，关注继承的类需要传入两个泛型，一个是数据层接口，另一个是实体类。\n@Service public class BookServiceImpl extends ServiceImpl\u0026lt;BookDao, Book\u0026gt; implements IBookService { @Autowired private BookDao bookDao; //添加非通用操作API } ​\t如果感觉MyBatisPlus提供的功能不足以支撑你的使用需要（其实是一定不能支撑的，因为需求不可能是通用的），在原始接口基础上接着定义新的API接口就行了，此处不再说太多了，就是自定义自己的操作了，但是不要和已有的API接口名冲突即可。\n总结\n使用通用接口（ISerivce）快速开发Service 使用通用实现类（ServiceImpl\u0026lt;M,T\u0026gt;）快速开发ServiceImpl 可以在通用接口基础上做功能重载或功能追加 注意重载时不要覆盖原始操作，避免原始提供的功能丢失 6.表现层开发\r​\t终于做到表现层了，做了这么多都是基础工作。其实你现在回头看看，哪里还有什么SpringBoot的影子？前面1,2步就搞完了。继续完成表现层制作吧，咱们表现层的开发使用基于Restful的表现层接口开发，功能测试通过Postman工具进行。\n​\t表现层接口如下:\n@RestController @RequestMapping(\u0026#34;/books\u0026#34;) public class BookController2 { @Autowired private IBookService bookService; @GetMapping public List\u0026lt;Book\u0026gt; getAll(){ return bookService.list(); } @PostMapping public Boolean save(@RequestBody Book book){ return bookService.save(book); } @PutMapping public Boolean update(@RequestBody Book book){ return bookService.modify(book); } @DeleteMapping(\u0026#34;{id}\u0026#34;) public Boolean delete(@PathVariable Integer id){ return bookService.delete(id); } @GetMapping(\u0026#34;{id}\u0026#34;) public Book getById(@PathVariable Integer id){ return bookService.getById(id); } @GetMapping(\u0026#34;{currentPage}/{pageSize}\u0026#34;) public IPage\u0026lt;Book\u0026gt; getPage(@PathVariable int currentPage,@PathVariable int pageSize){ return bookService.getPage(currentPage,pageSize, null); } } ​\t在使用Postman测试时关注提交类型，对应上即可，不然就会报405的错误码了。\n普通GET请求\nPUT请求传递json数据，后台实用@RequestBody接收数据\nGET请求传递路径变量，后台实用@PathVariable接收数据\n总结\n基于Restful制作表现层接口 新增：POST 删除：DELETE 修改：PUT 查询：GET 接收参数 实体数据：@RequestBody 路径变量：@PathVariable 7.表现层消息一致性处理\r​\t目前我们通过Postman测试后业务层接口功能是通的，但是这样的结果给到前端开发者会出现一个小问题。不同的操作结果所展示的数据格式差异化严重。\n​\t增删改操作结果\ntrue ​\t查询单个数据操作结果\n{ \u0026#34;id\u0026#34;: 1, \u0026#34;type\u0026#34;: \u0026#34;计算机理论\u0026#34;, \u0026#34;name\u0026#34;: \u0026#34;Spring实战 第5版\u0026#34;, \u0026#34;description\u0026#34;: \u0026#34;Spring入门经典教程\u0026#34; } ​\t查询全部数据操作结果\n[ { \u0026#34;id\u0026#34;: 1, \u0026#34;type\u0026#34;: \u0026#34;计算机理论\u0026#34;, \u0026#34;name\u0026#34;: \u0026#34;Spring实战 第5版\u0026#34;, \u0026#34;description\u0026#34;: \u0026#34;Spring入门经典教程\u0026#34; }, { \u0026#34;id\u0026#34;: 2, \u0026#34;type\u0026#34;: \u0026#34;计算机理论\u0026#34;, \u0026#34;name\u0026#34;: \u0026#34;Spring 5核心原理与30个类手写实战\u0026#34;, \u0026#34;description\u0026#34;: \u0026#34;十年沉淀之作\u0026#34; } ] ​\t每种不同操作返回的数据格式都不一样，而且还不知道以后还会有什么格式，这样的结果让前端人员看了是很容易让人崩溃的，必须将所有操作的操作结果数据格式统一起来，需要设计表现层返回结果的模型类，用于后端与前端进行数据格式统一，也称为前后端数据协议\n@Data public class R { private Boolean flag; private Object data; } ​\t其中flag用于标识操作是否成功，data用于封装操作数据，现在的数据格式就变了\n{ \u0026#34;flag\u0026#34;: true, \u0026#34;data\u0026#34;:{ \u0026#34;id\u0026#34;: 1, \u0026#34;type\u0026#34;: \u0026#34;计算机理论\u0026#34;, \u0026#34;name\u0026#34;: \u0026#34;Spring实战 第5版\u0026#34;, \u0026#34;description\u0026#34;: \u0026#34;Spring入门经典教程\u0026#34; } } ​\t表现层开发格式也需要转换一下\n​\t结果这么一折腾，全格式统一，现在后端发送给前端的数据格式就统一了，免去了不少前端解析数据的烦恼。\n总结\n设计统一的返回值结果类型便于前端开发读取数据\n返回值结果类型可以根据需求自行设定，没有固定格式\n返回值结果模型类用于后端与前端进行数据格式统一，也称为前后端数据协议\n8.前后端联通性测试\r​\t后端的表现层接口开发完毕，就可以进行前端的开发了。\n​\t将前端人员开发的页面保存到lresources目录下的static目录中，建议执行maven的clean生命周期，避免缓存的问题出现。\n​\t​\t在进行具体的功能开发之前，先做联通性的测试，通过页面发送异步提交（axios），这一步调试通过后再进行进一步的功能开发。\n//列表 getAll() { axios.get(\u0026#34;/books\u0026#34;).then((res)=\u0026gt;{ console.log(res.data); }); }, ​\t只要后台代码能够正常工作，前端能够在日志中接收到数据，就证明前后端是通的，也就可以进行下一步的功能开发了。\n总结\n单体项目中页面放置在resources/static目录下 created钩子函数用于初始化页面时发起调用 页面使用axios发送异步请求获取数据后确认前后端是否联通 9.页面基础功能开发\rF-1.列表功能（非分页版）\r​\t列表功能主要操作就是加载完数据，将数据展示到页面上，此处要利用VUE的数据模型绑定，发送请求得到数据，然后页面上读取指定数据即可。\n​\t页面数据模型定义\ndata:{ dataList: [],\t//当前页要展示的列表数据 ... }, ​\t异步请求获取数据\n//列表 getAll() { axios.get(\u0026#34;/books\u0026#34;).then((res)=\u0026gt;{ this.dataList = res.data.data; }); }, ​\t这样在页面加载时就可以获取到数据，并且由VUE将数据展示到页面上了。\n总结：\n将查询数据返回到页面，利用前端数据绑定进行数据展示 F-2.添加功能\r​\t添加功能用于收集数据的表单是通过一个弹窗展示的，因此在添加操作前首先要进行弹窗的展示，添加后隐藏弹窗即可。因为这个弹窗一直存在，因此当页面加载时首先设置这个弹窗为不可显示状态，需要展示，切换状态即可。\n​\t默认状态\ndata:{ dialogFormVisible: false,\t//添加表单是否可见 ... }, ​\t切换为显示状态\n//弹出添加窗口 handleCreate() { this.dialogFormVisible = true; }, ​\t由于每次添加数据都是使用同一个弹窗录入数据，所以每次操作的痕迹将在下一次操作时展示出来，需要在每次操作之前清理掉上次操作的痕迹。\n​\t定义清理数据操作\n//重置表单 resetForm() { this.formData = {}; }, ​\t切换弹窗状态时清理数据\n//弹出添加窗口 handleCreate() { this.dialogFormVisible = true; this.resetForm(); }, ​\t至此准备工作完成，下面就要调用后台完成添加操作了。\n​\t添加操作\n//添加 handleAdd () { //发送异步请求 axios.post(\u0026#34;/books\u0026#34;,this.formData).then((res)=\u0026gt;{ //如果操作成功，关闭弹层，显示数据 if(res.data.flag){ this.dialogFormVisible = false; this.$message.success(\u0026#34;添加成功\u0026#34;); }else { this.$message.error(\u0026#34;添加失败\u0026#34;); } }).finally(()=\u0026gt;{ this.getAll(); }); }, 将要保存的数据传递到后台，通过post请求的第二个参数传递json数据到后台 根据返回的操作结果决定下一步操作 如何是true就关闭添加窗口，显示添加成功的消息 如果是false保留添加窗口，显示添加失败的消息 无论添加是否成功，页面均进行刷新，动态加载数据（对getAll操作发起调用） ​\t取消添加操作\n//取消 cancel(){ this.dialogFormVisible = false; this.$message.info(\u0026#34;操作取消\u0026#34;); }, 总结\n请求方式使用POST调用后台对应操作 添加操作结束后动态刷新页面加载数据 根据操作结果不同，显示对应的提示信息 弹出添加Div时清除表单数据 F-3.删除功能\r​\t模仿添加操作制作删除功能，差别之处在于删除操作仅传递一个待删除的数据id到后台即可。\n​\t删除操作\n// 删除 handleDelete(row) { axios.delete(\u0026#34;/books/\u0026#34;+row.id).then((res)=\u0026gt;{ if(res.data.flag){ this.$message.success(\u0026#34;删除成功\u0026#34;); }else{ this.$message.error(\u0026#34;删除失败\u0026#34;); } }).finally(()=\u0026gt;{ this.getAll(); }); }, ​\t删除操作提示信息\n// 删除 handleDelete(row) { //1.弹出提示框 this.$confirm(\u0026#34;此操作永久删除当前数据，是否继续？\u0026#34;,\u0026#34;提示\u0026#34;,{ type:\u0026#39;info\u0026#39; }).then(()=\u0026gt;{ //2.做删除业务 axios.delete(\u0026#34;/books/\u0026#34;+row.id).then((res)=\u0026gt;{ if(res.data.flag){ this.$message.success(\u0026#34;删除成功\u0026#34;); }else{ this.$message.error(\u0026#34;删除失败\u0026#34;); } }).finally(()=\u0026gt;{ this.getAll(); }); }).catch(()=\u0026gt;{ //3.取消删除 this.$message.info(\u0026#34;取消删除操作\u0026#34;); }); }，\t总结\n请求方式使用Delete调用后台对应操作 删除操作需要传递当前行数据对应的id值到后台 删除操作结束后动态刷新页面加载数据 根据操作结果不同，显示对应的提示信息 删除操作前弹出提示框避免误操作 F-4.修改功能\r​\t修改功能可以说是列表功能、删除功能与添加功能的合体。几个相似点如下：\n页面也需要有一个弹窗用来加载修改的数据，这一点与添加相同，都是要弹窗\n弹出窗口中要加载待修改的数据，而数据需要通过查询得到，这一点与查询全部相同，都是要查数据\n查询操作需要将要修改的数据id发送到后台，这一点与删除相同，都是传递id到后台\n查询得到数据后需要展示到弹窗中，这一点与查询全部相同，都是要通过数据模型绑定展示数据\n修改数据时需要将被修改的数据传递到后台，这一点与添加相同，都是要传递数据\n所以整体上来看，修改功能就是前面几个功能的大合体\n查询并展示数据\n//弹出编辑窗口 handleUpdate(row) { axios.get(\u0026#34;/books/\u0026#34;+row.id).then((res)=\u0026gt;{ if(res.data.flag){ //展示弹层，加载数据 this.formData = res.data.data; this.dialogFormVisible4Edit = true; }else{ this.$message.error(\u0026#34;数据同步失败，自动刷新\u0026#34;); } }); }, ​\t修改操作\n//修改 handleEdit() { axios.put(\u0026#34;/books\u0026#34;,this.formData).then((res)=\u0026gt;{ //如果操作成功，关闭弹层并刷新页面 if(res.data.flag){ this.dialogFormVisible4Edit = false; this.$message.success(\u0026#34;修改成功\u0026#34;); }else { this.$message.error(\u0026#34;修改失败，请重试\u0026#34;); } }).finally(()=\u0026gt;{ this.getAll(); }); }, 总结\n加载要修改数据通过传递当前行数据对应的id值到后台查询数据（同删除与查询全部） 利用前端双向数据绑定将查询到的数据进行回显（同查询全部） 请求方式使用PUT调用后台对应操作（同新增传递数据） 修改操作结束后动态刷新页面加载数据（同新增） 根据操作结果不同，显示对应的提示信息（同新增） ​\n10.业务消息一致性处理\r​\t目前的功能制作基本上达成了正常使用的情况，什么叫正常使用呢？也就是这个程序不出BUG，如果我们搞一个BUG出来，你会发现程序马上崩溃掉。比如后台手工抛出一个异常，看看前端接收到的数据什么样子。\n{ \u0026#34;timestamp\u0026#34;: \u0026#34;2021-09-15T03:27:31.038+00:00\u0026#34;, \u0026#34;status\u0026#34;: 500, \u0026#34;error\u0026#34;: \u0026#34;Internal Server Error\u0026#34;, \u0026#34;path\u0026#34;: \u0026#34;/books\u0026#34; } ​\t面对这种情况，前端的同学又不会了，这又是什么格式？怎么和之前的格式不一样？\n{ \u0026#34;flag\u0026#34;: true, \u0026#34;data\u0026#34;:{ \u0026#34;id\u0026#34;: 1, \u0026#34;type\u0026#34;: \u0026#34;计算机理论\u0026#34;, \u0026#34;name\u0026#34;: \u0026#34;Spring实战 第5版\u0026#34;, \u0026#34;description\u0026#34;: \u0026#34;Spring入门经典教程\u0026#34; } } ​\t看来不仅要对正确的操作数据格式做处理，还要对错误的操作数据格式做同样的格式处理。\n​\t首先在当前的数据结果中添加消息字段，用来兼容后台出现的操作消息。\n@Data public class R{ private Boolean flag; private Object data; private String msg;\t//用于封装消息 } ​\t后台代码也要根据情况做处理，当前是模拟的错误。\n@PostMapping public R save(@RequestBody Book book) throws IOException { Boolean flag = bookService.insert(book); return new R(flag , flag ? \u0026#34;添加成功^_^\u0026#34; : \u0026#34;添加失败-_-!\u0026#34;); } ​\t然后在表现层做统一的异常处理，使用SpringMVC提供的异常处理器做统一的异常处理。\n@RestControllerAdvice public class ProjectExceptionAdvice { @ExceptionHandler(Exception.class) public R doOtherException(Exception ex){ //记录日志 //发送消息给运维 //发送邮件给开发人员,ex对象发送给开发人员 ex.printStackTrace(); return new R(false,null,\u0026#34;系统错误，请稍后再试！\u0026#34;); } } ​\t页面上得到数据后，先判定是否有后台传递过来的消息，标志就是当前操作是否成功，如果返回操作结果false，就读取后台传递的消息。\n//添加 handleAdd () { //发送ajax请求 axios.post(\u0026#34;/books\u0026#34;,this.formData).then((res)=\u0026gt;{ //如果操作成功，关闭弹层，显示数据 if(res.data.flag){ this.dialogFormVisible = false; this.$message.success(\u0026#34;添加成功\u0026#34;); }else { this.$message.error(res.data.msg);\t//消息来自于后台传递过来，而非固定内容 } }).finally(()=\u0026gt;{ this.getAll(); }); }, 总结\n使用注解@RestControllerAdvice定义SpringMVC异常处理器用来处理异常的 异常处理器必须被扫描加载，否则无法生效 表现层返回结果的模型类中添加消息属性用来传递消息到页面 ​\n11.页面功能开发\rF-5.分页功能\r​\t分页功能的制作用于替换前面的查询全部，其中要使用到elementUI提供的分页组件。\n\u0026lt;!--分页组件--\u0026gt; \u0026lt;div class=\u0026#34;pagination-container\u0026#34;\u0026gt; \u0026lt;el-pagination class=\u0026#34;pagiantion\u0026#34; @current-change=\u0026#34;handleCurrentChange\u0026#34; :current-page=\u0026#34;pagination.currentPage\u0026#34; :page-size=\u0026#34;pagination.pageSize\u0026#34; layout=\u0026#34;total, prev, pager, next, jumper\u0026#34; :total=\u0026#34;pagination.total\u0026#34;\u0026gt; \u0026lt;/el-pagination\u0026gt; \u0026lt;/div\u0026gt; ​\t为了配合分页组件，封装分页对应的数据模型。\ndata:{ pagination: {\t//分页相关模型数据 currentPage: 1,\t//当前页码 pageSize:10,\t//每页显示的记录数 total:0,\t//总记录数 } }, ​\t修改查询全部功能为分页查询，通过路径变量传递页码信息参数。\ngetAll() { axios.get(\u0026#34;/books/\u0026#34;+this.pagination.currentPage+\u0026#34;/\u0026#34;+this.pagination.pageSize).then((res) =\u0026gt; { }); }, ​\t后台提供对应的分页功能。\n@GetMapping(\u0026#34;/{currentPage}/{pageSize}\u0026#34;) public R getAll(@PathVariable Integer currentPage,@PathVariable Integer pageSize){ IPage\u0026lt;Book\u0026gt; pageBook = bookService.getPage(currentPage, pageSize); return new R(null != pageBook ,pageBook); } ​\t页面根据分页操作结果读取对应数据，并进行数据模型绑定。\ngetAll() { axios.get(\u0026#34;/books/\u0026#34;+this.pagination.currentPage+\u0026#34;/\u0026#34;+this.pagination.pageSize).then((res) =\u0026gt; { this.pagination.total = res.data.data.total; this.pagination.currentPage = res.data.data.current; this.pagination.pagesize = res.data.data.size; this.dataList = res.data.data.records; }); }, ​\t对切换页码操作设置调用当前分页操作。\n//切换页码 handleCurrentChange(currentPage) { this.pagination.currentPage = currentPage; this.getAll(); }, 总结\n使用el分页组件 定义分页组件绑定的数据模型 异步调用获取分页数据 分页数据页面回显 F-6.删除功能维护\r​\t由于使用了分页功能，当最后一页只有一条数据时，删除操作就会出现BUG，最后一页无数据但是独立展示，对分页查询功能进行后台功能维护，如果当前页码值大于最大页码值，重新执行查询。其实这个问题解决方案很多，这里给出比较简单的一种处理方案。\n@GetMapping(\u0026#34;{currentPage}/{pageSize}\u0026#34;) public R getPage(@PathVariable int currentPage,@PathVariable int pageSize){ IPage\u0026lt;Book\u0026gt; page = bookService.getPage(currentPage, pageSize); //如果当前页码值大于了总页码值，那么重新执行查询操作，使用最大页码值作为当前页码值 if( currentPage \u0026gt; page.getPages()){ page = bookService.getPage((int)page.getPages(), pageSize); } return new R(true, page); } F-7.条件查询功能\r​\t最后一个功能来做条件查询，其实条件查询可以理解为分页查询的时候除了携带分页数据再多带几个数据的查询。这些多带的数据就是查询条件。比较一下不带条件的分页查询与带条件的分页查询差别之处，这个功能就好做了\n页面封装的数据：带不带条件影响的仅仅是一次性传递到后台的数据总量，由传递2个分页相关数据转换成2个分页数据加若干个条件\n后台查询功能：查询时由不带条件，转换成带条件，反正不带条件的时候查询条件对象使用的是null，现在换成具体条件，差别不大\n查询结果：不管带不带条件，出来的数据只是有数量上的差别，其他都差别，这个可以忽略\n经过上述分析，看来需要在页面发送请求的格式方面做一定的修改，后台的调用数据层操作时发送修改，其他没有区别。\n页面发送请求时，两个分页数据仍然使用路径变量，其他条件采用动态拼装url参数的形式传递。\n页面封装查询条件字段\npagination: {\t//分页相关模型数据 currentPage: 1,\t//当前页码 pageSize:10,\t//每页显示的记录数 total:0,\t//总记录数 name: \u0026#34;\u0026#34;, type: \u0026#34;\u0026#34;, description: \u0026#34;\u0026#34; }, 页面添加查询条件字段对应的数据模型绑定名称\n\u0026lt;div class=\u0026#34;filter-container\u0026#34;\u0026gt; \u0026lt;el-input placeholder=\u0026#34;图书类别\u0026#34; v-model=\u0026#34;pagination.type\u0026#34; class=\u0026#34;filter-item\u0026#34;/\u0026gt; \u0026lt;el-input placeholder=\u0026#34;图书名称\u0026#34; v-model=\u0026#34;pagination.name\u0026#34; class=\u0026#34;filter-item\u0026#34;/\u0026gt; \u0026lt;el-input placeholder=\u0026#34;图书描述\u0026#34; v-model=\u0026#34;pagination.description\u0026#34; class=\u0026#34;filter-item\u0026#34;/\u0026gt; \u0026lt;el-button @click=\u0026#34;getAll()\u0026#34; class=\u0026#34;dalfBut\u0026#34;\u0026gt;查询\u0026lt;/el-button\u0026gt; \u0026lt;el-button type=\u0026#34;primary\u0026#34; class=\u0026#34;butT\u0026#34; @click=\u0026#34;handleCreate()\u0026#34;\u0026gt;新建\u0026lt;/el-button\u0026gt; \u0026lt;/div\u0026gt; 将查询条件组织成url参数，添加到请求url地址中，这里可以借助其他类库快速开发，当前使用手工形式拼接，降低学习要求\ngetAll() { //1.获取查询条件,拼接查询条件 param = \u0026#34;?name=\u0026#34;+this.pagination.name; param += \u0026#34;\u0026amp;type=\u0026#34;+this.pagination.type; param += \u0026#34;\u0026amp;description=\u0026#34;+this.pagination.description; console.log(\u0026#34;-----------------\u0026#34;+ param); axios.get(\u0026#34;/books/\u0026#34;+this.pagination.currentPage+\u0026#34;/\u0026#34;+this.pagination.pageSize+param).then((res) =\u0026gt; { this.dataList = res.data.data.records; }); }, 后台代码中定义实体类封查询条件\n@GetMapping(\u0026#34;{currentPage}/{pageSize}\u0026#34;) public R getAll(@PathVariable int currentPage,@PathVariable int pageSize,Book book) { System.out.println(\u0026#34;参数=====\u0026gt;\u0026#34;+book); IPage\u0026lt;Book\u0026gt; pageBook = bookService.getPage(currentPage,pageSize); return new R(null != pageBook ,pageBook); } 对应业务层接口与实现类进行修正\npublic interface IBookService extends IService\u0026lt;Book\u0026gt; { IPage\u0026lt;Book\u0026gt; getPage(Integer currentPage,Integer pageSize,Book queryBook); } @Service public class BookServiceImpl2 extends ServiceImpl\u0026lt;BookDao,Book\u0026gt; implements IBookService { public IPage\u0026lt;Book\u0026gt; getPage(Integer currentPage,Integer pageSize,Book queryBook){ IPage page = new Page(currentPage,pageSize); LambdaQueryWrapper\u0026lt;Book\u0026gt; lqw = new LambdaQueryWrapper\u0026lt;Book\u0026gt;(); lqw.like(Strings.isNotEmpty(queryBook.getName()),Book::getName,queryBook.getName()); lqw.like(Strings.isNotEmpty(queryBook.getType()),Book::getType,queryBook.getType()); lqw.like(Strings.isNotEmpty(queryBook.getDescription()),Book::getDescription,queryBook.getDescription()); return bookDao.selectPage(page,lqw); } } 页面回显数据\ngetAll() { //1.获取查询条件,拼接查询条件 param = \u0026#34;?name=\u0026#34;+this.pagination.name; param += \u0026#34;\u0026amp;type=\u0026#34;+this.pagination.type; param += \u0026#34;\u0026amp;description=\u0026#34;+this.pagination.description; console.log(\u0026#34;-----------------\u0026#34;+ param); axios.get(\u0026#34;/books/\u0026#34;+this.pagination.currentPage+\u0026#34;/\u0026#34;+this.pagination.pageSize+param).then((res) =\u0026gt; { this.pagination.total = res.data.data.total; this.pagination.currentPage = res.data.data.current; this.pagination.pagesize = res.data.data.size; this.dataList = res.data.data.records; }); }, 总结\n定义查询条件数据模型（当前封装到分页数据模型中） 异步调用分页功能并通过请求参数传递数据到后台 基础篇完结\r​\t基础篇到这里就全部结束了，在基础篇中带着大家学习了如何创建一个SpringBoot工程，然后学习了SpringBoot的基础配置语法格式，接下来对常见的市面上的实用技术做了整合，最后通过一个小的案例对前面学习的内容做了一个综合应用。整体来说就是一个最基本的入门，关于SpringBoot的实际开发其实接触的还是很少的，我们到实用篇和原理篇中继续吧，各位小伙伴，加油学习，再见。\nSpringBoot运维实用篇\r​\t基础篇发布以后，看到了很多小伙伴在网上的留言，也帮助超过100位小伙伴解决了一些遇到的问题，并且已经发现了部分问题具有典型性，预计将有些问题在后面篇章的合适位置添加到本套课程中，作为解决方案提供给大家。\n​\t从此刻开始，咱们就要进入到实用篇的学习了。实用篇是在基础篇的根基之上，补全SpringBoot的知识图谱。比如在基础篇中只给大家讲了yaml的语法格式，但是具体写yaml文件的时候还有很多实用开发过程中的坑，这些在实用篇中都要进行学习。\n​\t实用篇共分为两块内容，分别是运维实用篇和开发实用篇。其实划分的标准是我自己制定的，因为这里面的知识有一些还是比较散的，做两个阶段的划分是为了更好的将同类知识点进行归类，帮助学习者找到知识之间的关联性，这样有助于知识的记忆存储转换，经过一系列的知识反复出现与强化练习，将临时记忆转换成永久性记忆。做课程嘛，不能仅以讲完为目标，要以学习者的学习收获为目标，这也是我这么多年教学秉承的基本理念。\n​\t下面就从运维实用篇开始讲，在运维实用篇中，我给学习者的定位是玩转配置，为开发实用篇中做各种技术的整合做好准备工作。与开发实用篇相比，运维实用篇的内容显得略微单薄，并且有部分知识模块在运维实用篇和开发实用篇中都要讲一部分，这些内容都后置到开发实用篇中了。废话不说了，先看看运维实用篇中都包含哪些内容：\nSpringBoot程序的打包与运行 配置高级 多环境开发 日志 ​\t下面开启第一部分SpringBoot程序打包与运行的学习\nYW-1.SpringBoot程序的打包与运行\r​\t刚开始做开发学习的小伙伴可能在有一个知识上面有错误的认知，我们天天写程序是在Idea下写的，运行也是在Idea下运行的。\n​\t但是实际开发完成后，我们的项目是不可能运行在自己的电脑上的。\n​\t我们以后制作的程序是运行在专用的服务器上的，简单说就是将你做的程序放在一台独立运行的电脑上，这台电脑要比你开发使用的计算机更专业，并且安全等级各个方面要远超过你现在的电脑。\n​\t那我们的程序如何放置在这台专用的电脑上呢，这就要将我们的程序先组织成一个文件，然后将这个文件传输到这台服务器上。这里面就存在两个过程，一个是打包的过程，另一个是运行的过程。\n温馨提示\n​\t企业项目上线为了保障环境适配性会采用下面流程发布项目，这里不讨论此过程。\n开发部门使用Git、SVN等版本控制工具上传工程到版本服务器 服务器使用版本控制工具下载工程 服务器上使用Maven工具在当前真机环境下重新构建项目 启动服务 ​\t继续说我们的打包和运行过程。所谓打包指将程序转换成一个可执行的文件，所谓运行指不依赖开发环境执行打包产生的文件。上述两个操作都有对应的命令可以快速执行。\n程序打包\r​\tSpringBoot程序是基于Maven创建的，在Maven中提供有打包的指令，叫做package。本操作可以在Idea环境下执行。\nmvn package ​\t打包后会产生一个与工程名类似的jar文件，其名称是由模块名+版本号+.jar组成的。\n程序运行\r​\t程序包打好以后，就可以直接执行了。在程序包所在路径下，执行指令。\njava -jar 工程包名.jar ​\t执行程序打包指令后，程序正常运行，与在Idea下执行程序没有区别。\n​\t特别关注：如果你的计算机中没有安装java的jdk环境，是无法正确执行上述操作的，因为程序执行使用的是java指令。\n​\t特别关注：在使用向导创建SpringBoot工程时，pom.xml文件中会有如下配置，这一段配置千万不能删除，否则打包后无法正常执行程序。\n\u0026lt;build\u0026gt; \u0026lt;plugins\u0026gt; \u0026lt;plugin\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-maven-plugin\u0026lt;/artifactId\u0026gt; \u0026lt;/plugin\u0026gt; \u0026lt;/plugins\u0026gt; \u0026lt;/build\u0026gt; 总结\nSpringBoot工程可以基于java环境下独立运行jar文件启动服务 SpringBoot工程执行mvn命令package进行打包 执行jar命令：java –jar 工程名.jar SpringBoot程序打包失败处理\r​\t有些小伙伴打包以后执行会出现一些问题，导致程序无法正常执行，例如下面的现象\n​\t要想搞清楚这个问题就要说说.jar文件的工作机制了，知道了这个东西就知道如何避免此类问题的发生了。\n​\t搞java开发平时会接触很多jar包，比如mysql的驱动jar包，而上面我们打包程序后得到的也是一个jar文件。这个时候如果你使用上面的java -jar指令去执行mysql的驱动jar包就会出现上述不可执行的现象，而我们的SpringBoot项目为什么能执行呢？其实是因为打包方式不一样。\n​\t在SpringBoot工程的pom.xml中有下面这组配置，这组配置决定了打包出来的程序包是否可以执行。\n\u0026lt;build\u0026gt; \u0026lt;plugins\u0026gt; \u0026lt;plugin\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-maven-plugin\u0026lt;/artifactId\u0026gt; \u0026lt;/plugin\u0026gt; \u0026lt;/plugins\u0026gt; \u0026lt;/build\u0026gt; ​\t我们分别开启这段配置和注释掉这段配置分别执行两次打包，然后观察两次打包后的程序包的差别，共有3处比较明显的特征\n打包后文件的大小不同 打包后所包含的内容不同 打包程序中个别文件内容不同 ​\t先看第一个现象，文件大小不同。带有配置时打包生成的程序包大小如下：\n​\t不难看出，带有配置的程序包体积比不带配置的大了30倍，那这里面都有什么呢？能差这么多？下面看看里面的内容有什么区别。\n​\t​\t我们发现内容也完全不一样，仅有一个目录是一样的，叫做META-INF。打开容量大的程序包中的BOOT-INF目录下的classes目录，我们发现其中的内容居然和容量小的程序包中的内容完全一样。\n​\t​\t原来大的程序包中除了包含小的程序包中的内容，还有别的东西。都有什么呢？回到BOOT-INF目录下，打开lib目录，里面显示了很多个jar文件。\n​\t​\t仔细翻阅不难发现，这些jar文件都是我们制作这个工程时导入的坐标对应的文件。大概可以想明白了，SpringBoot程序为了让自己打包生成的程序可以独立运行，不仅将项目中自己开发的内容进行了打包，还把当前工程运行需要使用的jar包全部打包进来了。为什么这样做呢？就是为了可以独立运行。不依赖程序包外部的任何资源可以独立运行当前程序。这也是为什么大的程序包容量是小的程序包容量的30倍的主要原因。\n​\t再看看大程序包还有什么不同之处，在最外层目录包含一个org目录，进入此目录，目录名是org\\springframework\\boot\\loader，在里面可以找到一个JarLauncher.class的文件，先记得这个文件。再看这套目录名，明显是一个Spring的目录名，为什么要把Spring框架的东西打包到这个程序包中呢？不清楚。\n​\t回到两个程序包的最外层目录，查看名称相同的文件夹META-INF下都有一个叫做MANIFEST.MF的文件，但是大小不同，打开文件，比较内容区别\n小容量文件的MANIFEST.MF\nManifest-Version: 1.0 Implementation-Title: springboot_08_ssmp Implementation-Version: 0.0.1-SNAPSHOT Build-Jdk-Spec: 1.8 Created-By: Maven Jar Plugin 3.2.0 大容量文件的MANIFEST.MF\nManifest-Version: 1.0 Spring-Boot-Classpath-Index: BOOT-INF/classpath.idx Implementation-Title: springboot_08_ssmp Implementation-Version: 0.0.1-SNAPSHOT Spring-Boot-Layers-Index: BOOT-INF/layers.idx Start-Class: com.itheima.SSMPApplication Spring-Boot-Classes: BOOT-INF/classes/ Spring-Boot-Lib: BOOT-INF/lib/ Build-Jdk-Spec: 1.8 Spring-Boot-Version: 2.5.4 Created-By: Maven Jar Plugin 3.2.0 Main-Class: org.springframework.boot.loader.JarLauncher ​\t大文件中明显比小文件中多了几行信息，其中最后一行信息是Main-Class: org.springframework.boot.loader.JarLauncher。这句话什么意思呢？如果使用java -jar执行此程序包，将执行Main-Class属性配置的类，这个类恰巧就是前面看到的那个文件。原来SpringBoot打包程序中出现Spring框架的东西是为这里服务的。而这个org.springframework.boot.loader.JarLauncher类内部要查找Start-Class属性中配置的类，并执行对应的类。这个属性在当前配置中也存在，对应的就是我们的引导类类名。\n​\t现在这组设定的作用就搞清楚了\nSpringBoot程序添加配置后会打出一个特殊的包，包含Spring框架部分功能，原始工程内容，原始工程依赖的jar包 首先读取MANIFEST.MF文件中的Main-Class属性，用来标记执行java -jar命令后运行的类 JarLauncher类执行时会找到Start-Class属性，也就是启动类类名 运行启动类时会运行当前工程的内容 运行当前工程时会使用依赖的jar包，从lib目录中查找 ​\t看来SpringBoot打出来了包为了能够独立运行，简直是煞费苦心，将所有需要使用的资源全部都添加到了这个包里。这就是为什么这个jar包能独立运行的原因。\n​\t再来看之前的报错信息：\n​\t由于打包时没有使用那段配置，结果打包后形成了一个普通的jar包，在MANIFEST.MF文件中也就没有了Main-Class对应的属性了，所以运行时提示找不到主清单属性，这就是报错的原因。\n​\t上述内容搞清楚对我们编程意义并不大，但是对各位小伙伴理清楚SpringBoot工程独立运行的机制是有帮助的。其实整体过程主要是带着大家分析，如果以后遇到了类似的问题，多给自己提问，多问一个为什么，兴趣自己就可以独立解决问题了。\n总结\nspring-boot-maven-plugin插件用于将当前程序打包成一个可以独立运行的程序包 命令行启动常见问题及解决方案\r​\t各位小伙伴在DOS环境下启动SpringBoot工程时，可能会遇到端口占用的问题。给大家一组命令，不用深入学习，备用吧。\n# 查询端口 netstat -ano # 查询指定端口 netstat -ano |findstr \u0026#34;端口号\u0026#34; # 根据进程PID查询进程名称 tasklist |findstr \u0026#34;进程PID号\u0026#34; # 根据PID杀死任务 taskkill /F /PID \u0026#34;进程PID号\u0026#34; # 根据进程名称杀死任务 taskkill -f -t -im \u0026#34;进程名称\u0026#34; ​\t关于打包与运行程序其实还有一系列的配置和参数，下面的内容中遇到再说，这里先开个头，知道如何打包和运行程序。\nSpringBoot项目快速启动（Linux版）\r​\t其实对于Linux系统下的程序运行与Windows系统下的程序运行差别不大，命令还是那组命令，只不过各位小伙伴可能对Linux指令不太熟悉，结果就会导致各种各样的问题发生。比如防火墙如何关闭，IP地址如何查询，JDK如何安装等等。这里不作为重点内容给大家普及了，了解一下整体过程就行了。\nYW-2.配置高级\r​\t关于配置在基础篇讲过一部分，基础篇的配置总体上来说就是让各位小伙伴掌握配置的格式。比如配置文件如何写啊，写好的数据如何读取啊，都是基础的语法级知识。在实用篇中就要集中在配置的应用这个方面了，下面就开始配置高级相关内容的第一部分学习，为什么说第一部分，因为在开发实用篇中还有对应的配置高级知识要进行学习。\nYW-2-1.临时属性设置\r​\t目前我们的程序包打好了，可以发布了。但是程序包打好以后，里面的配置都已经是固定的了，比如配置了服务器的端口是8080。如果我要启动项目，发现当前我的服务器上已经有应用启动起来并且占用了8080端口，这个时候就尴尬了。难道要重新把打包好的程序修改一下吗？比如我要把打包好的程序启动端口改成80。\n​\tSpringBoot提供了灵活的配置方式，如果你发现你的项目中有个别属性需要重新配置，可以使用临时属性的方式快速修改某些配置。方法也特别简单，在启动的时候添加上对应参数就可以了。\njava –jar springboot.jar –-server.port=80 ​\t上面的命令是启动SpringBoot程序包的命令，在命令输入完毕后，空一格，然后输入两个-号。下面按照属性名=属性值的形式添加对应参数就可以了。记得，这里的格式不是yaml中的书写格式，当属性存在多级名称时，中间使用点分隔，和properties文件中的属性格式完全相同。\n​\t如果你发现要修改的属性不止一个，可以按照上述格式继续写，属性与属性之间使用空格分隔。\njava –jar springboot.jar –-server.port=80 --logging.level.root=debug 属性加载优先级\r​\t现在我们的程序配置受两个地方控制了，第一配置文件，第二临时属性。并且我们发现临时属性的加载优先级要高于配置文件的。那是否还有其他的配置方式呢？其实是有的，而且还不少，打开官方文档中对应的内容，就可以查看配置读取的优先顺序。地址奉上：https://docs.spring.io/spring-boot/docs/current/reference/html/spring-boot-features.html#boot-features-external-config\n​\t我们可以看到，居然有14种配置的位置，而我们现在使用的是这里面的2个。第3条Config data说的就是使用配置文件，第11条Command line arguments说的就是使用命令行临时参数。而这14种配置的顺序就是SpringBoot加载配置的顺序，言外之意，命令行临时属性比配置文件的加载优先级高，所以这个列表上面的优先级低，下面的优先级高。其实这个东西不用背的，你就记得一点，你最终要什么效果，你自己是知道的，不管这个顺序是怎么个高低排序，开发时一定要配置成你要的顺序为准。这个顺序只是在你想不明白问题的时候帮助你分析罢了。\n​\t比如你现在加载了一个user.name属性。结果你发现出来的结果和你想的不一样，那肯定是别的优先级比你高的属性覆盖你的配置属性了，那你就可以看着这个顺序挨个排查。哪个位置有可能覆盖了你的属性。\n​\t我在课程评论区看到小伙伴学习基础篇的时候问这个问题了，就是这个原因造成的。在yaml中配置了user.name属性值，然后读取出来的时候居然不是自己的配置值，因为在系统属性中有一个属性叫做user.name，两个相互冲突了。而系统属性的加载优先顺序在上面这个列表中是5号，高于3号，所以SpringBoot最终会加载系统配置属性user.name。\n总结\n使用jar命令启动SpringBoot工程时可以使用临时属性替换配置文件中的属性 临时属性添加方式：java –jar 工程名.jar –-属性名=值 多个临时属性之间使用空格分隔 临时属性必须是当前boot工程支持的属性，否则设置无效 开发环境中使用临时属性\r​\t临时使用目前是有了，但是上线的时候通过命令行输入的临时属性必须是正确的啊，那这些属性配置值我们必须在开发环境中测试好才行。下面说一下开发环境中如何使用临时属性，其实就是Idea界面下如何操作了。\n​\t打开SpringBoot引导类的运行界面，在里面找到配置项。其中Program arguments对应的位置就是添加临时属性的，可以加几个试试效果。\n​\t做到这里其实可以产生一个思考了，如果对java编程熟悉的小伙伴应该知道，我们运行main方法的时候，如果想使用main方法的参数，也就是下面的args参数，就是在上面这个位置添加的参数。\npublic static void main(String[] args) { } ​\t原来是这样，通过这个args就可以获取到参数。再来看我们的引导类是如何书写的\npublic static void main(String[] args) { SpringApplication.run(SSMPApplication.class,args); } ​\t这个args参数居然传递给了run方法，看来在Idea中配置的临时参数就是通过这个位置传递到我们的程序中的。言外之意，这里如果不用这个args是不是就断开了外部传递临时属性的入口呢？是这样的，我们可以使用下面的调用方式，这样外部临时属性就无法进入到SpringBoot程序中了。\npublic static void main(String[] args) { SpringApplication.run(SSMPApplication.class); } ​\t或者还可以使用如下格式来玩这个操作，就是将配置不写在配置文件中，直接写成一个字符串数组，传递给程序入口。当然，这种做法并没有什么实际开发意义。\npublic static void main(String[] args) { String[] arg = new String[1]; arg[0] = \u0026#34;--server.port=8082\u0026#34;; SpringApplication.run(SSMPApplication.class, arg); } 总结\n启动SpringBoot程序时，可以选择是否使用命令行属性为SpringBoot程序传递启动属性 思考\n​\t现在使用临时属性可以在启动项目前临时更改配置了，但是新的问题又出来了。临时属性好用是好用，就是写的多了会很麻烦。比如我现在有个需求，上线的时候使用临时属性配置20个值，这下可麻烦了，能不能搞得简单点，集中管理一下呢？比如说搞个文件，加载指定文件？还真可以。怎么做呢？咱们下一节再说。\nYW-2-2.配置文件分类\r​\tSpringBoot提供了配置文件和临时属性的方式来对程序进行配置。前面一直说的是临时属性，这一节要说说配置文件了。其实这个配置文件我们一直在使用，只不过我们用的是SpringBoot提供的4级配置文件中的其中一个级别。4个级别分别是：\n类路径下配置文件（一直使用的是这个，也就是resources目录中的application.yml文件） 类路径下config目录下配置文件 程序包所在目录中配置文件 程序包所在目录中config目录下配置文件 ​\t好复杂，一个一个说。其实上述4种文件是提供给你了4种配置文件书写的位置，功能都是一样的，都是做配置的。那大家关心的就是差别了，没错，就是因为位置不同，产生了差异。总体上来说，4种配置文件如果都存在的话，有一个优先级的问题，说白了就是加入4个文件我都有，里面都有一样的配置，谁生效的问题。上面4个文件的加载优先顺序为\nfile ：config/application.yml 【最高】 file ：application.yml classpath：config/application.yml classpath：application.yml 【最低】 ​\t那为什么设计这种多种呢？说一个最典型的应用吧。\n场景A：你作为一个开发者，你做程序的时候为了方便自己写代码，配置的数据库肯定是连接你自己本机的，咱们使用4这个级别，也就是之前一直用的application.yml。 场景B：现在项目开发到了一个阶段，要联调测试了，连接的数据库是测试服务器的数据库，肯定要换一组配置吧。你可以选择把你之前的文件中的内容都改了，目前还不麻烦。 场景C：测试完了，一切OK。你继续写你的代码，你发现你原来写的配置文件被改成测试服务器的内容了，你要再改回来。现在明白了不？场景B中把你的内容都改掉了，你现在要重新改回来，以后呢？改来改去吗？ ​\t解决方案很简单，用上面的3这个级别的配置文件就可以快速解决这个问题，再写一个配置就行了。两个配置文件共存，因为config目录中的配置加载优先级比你的高，所以配置项如果和级别4里面的内容相同就覆盖了，这样是不是很简单？\n​\t级别1和2什么时候使用呢？程序打包以后就要用这个级别了，管你程序里面配置写的是什么？我的级别高，可以轻松覆盖你，就不用考虑这些配置冲突的问题了。\n总结\n配置文件分为4种\n项目类路径配置文件：服务于开发人员本机开发与测试 项目类路径config目录中配置文件：服务于项目经理整体调控 工程路径配置文件：服务于运维人员配置涉密线上环境 工程路径config目录中配置文件：服务于运维经理整体调控 多层级配置文件间的属性采用叠加并覆盖的形式作用于程序\nYW-2-3.自定义配置文件\r​\t之前咱们做配置使用的配置文件都是application.yml，其实这个文件也是可以改名字的，这样方便维护。比如我2020年4月1日搞活动，走了一组配置，2020年5月1日活动取消，恢复原始配置，这个时候只需要重新更换一下配置文件就可以了。但是你总不能在原始配置文件上修改吧，不然搞完活动以后，活动的配置就留不下来了，不利于维护。\n​\t自定义配置文件方式有如下两种：\n方式一：使用临时属性设置配置文件名，注意仅仅是名称，不要带扩展名\n方式二：使用临时属性设置配置文件路径，这个是全路径名\n​\t也可以设置加载多个配置文件\n​\t使用的属性一个是spring.config.name，另一个是spring.config.location，这个一定要区别清楚。\n温馨提示\n​\t我们现在研究的都是SpringBoot单体项目，就是单服务器版本。其实企业开发现在更多的是使用基于SpringCloud技术的多服务器项目。这种配置方式和我们现在学习的完全不一样，所有的服务器将不再设置自己的配置文件，而是通过配置中心获取配置，动态加载配置信息。为什么这样做？集中管理。这里不再说这些了，后面再讲这些东西。\n总结\n配置文件可以修改名称，通过启动参数设定 配置文件可以修改路径，通过启动参数设定 微服务开发中配置文件通过配置中心进行设置 YW-3.多环境开发\r​\t讲的内容距离线上开发越来越近了，下面说一说多环境开发问题。\n​\t什么是多环境？其实就是说你的电脑上写的程序最终要放到别人的服务器上去运行。每个计算机环境不一样，这就是多环境。常见的多环境开发主要兼顾3种环境设置，开发环境——自己用的，测试环境——自己公司用的，生产环境——甲方爸爸用的。因为这是绝对不同的三台电脑，所以环境肯定有所不同，比如连接的数据库不一样，设置的访问端口不一样等等。\nYW-3-1.多环境开发（yaml单一文件版）\r​\t那什么是多环境开发？就是针对不同的环境设置不同的配置属性即可。比如你自己开发时，配置你的端口如下：\nserver: port: 80 ​\t如何想设计两组环境呢？中间使用三个减号分隔开\nserver: port: 80 --- server: port: 81 ​\t如何区分两种环境呢？起名字呗\nspring: profiles: pro server: port: 80 --- spring: profiles: dev server: port: 81 ​\t那用哪一个呢？设置默认启动哪个就可以了\nspring: profiles: active: pro\t# 启动pro --- spring: profiles: pro server: port: 80 --- spring: profiles: dev server: port: 81 ​\t就这么简单，再多来一组环境也OK\nspring: profiles: active: pro\t# 启动pro --- spring: profiles: pro server: port: 80 --- spring: profiles: dev server: port: 81 --- spring: profiles: test server: port: 82 ​\t其中关于环境名称定义上述格式是过时格式，标准格式如下\nspring: config: activate: on-profile: pro 总结\n多环境开发需要设置若干种常用环境，例如开发、生产、测试环境 yaml格式中设置多环境使用\u0026mdash;区分环境设置边界 每种环境的区别在于加载的配置属性不同 启用某种环境时需要指定启动时使用该环境 YW-3-2.多环境开发（yaml多文件版）\r​\t将所有的配置都放在一个配置文件中，尤其是每一个配置应用场景都不一样，这显然不合理，于是就有了将一个配置文件拆分成多个配置文件的想法。拆分后，每个配置文件中写自己的配置，主配置文件中写清楚用哪一个配置文件就好了。\n主配置文件\nspring: profiles: active: pro\t# 启动pro 环境配置文件\nserver: port: 80 ​\t环境配置文件因为每一个都是配置自己的项，所以连名字都不用写里面了。那问题是如何区分这是哪一组配置呢？使用文件名区分。\napplication-pro.yaml\nserver: port: 80 application-dev.yaml\nserver: port: 81 ​\t文件的命名规则为：application-环境名.yml。\n​\t在配置文件中，如果某些配置项所有环境都一样，可以将这些项写入到主配置中，只有哪些有区别的项才写入到环境配置文件中。\n主配置文件中设置公共配置（全局） 环境分类配置文件中常用于设置冲突属性（局部） 总结\n可以使用独立配置文件定义环境属性\n独立配置文件便于线上系统维护更新并保障系统安全性\nYW-3-3.多环境开发（properties多文件版）\r​\tSpringBoot最早期提供的配置文件格式是properties格式的，这种格式的多环境配置也了解一下吧。\n主配置文件\nspring.profiles.active=pro 环境配置文件\napplication-pro.properties\nserver.port=80 application-dev.properties\nserver.port=81 ​\t文件的命名规则为：application-环境名.properties。\n总结\nproperties文件多环境配置仅支持多文件格式 YW-3-4.多环境开发独立配置文件书写技巧\r​\t作为程序员在搞配置的时候往往处于一种分久必合合久必分的局面。开始先写一起，后来为了方便维护就拆分。对于多环境开发也是如此，下面给大家说一下如何基于多环境开发做配置独立管理，务必掌握。\n准备工作\n​\t将所有的配置根据功能对配置文件中的信息进行拆分，并制作成独立的配置文件，命名规则如下\napplication-devDB.yml application-devRedis.yml application-devMVC.yml 使用\n​\t使用include属性在激活指定环境的情况下，同时对多个环境进行加载使其生效，多个环境间使用逗号分隔\nspring: profiles: active: dev include: devDB,devRedis,devMVC ​\t比较一下，现在相当于加载dev配置时，再加载对应的3组配置，从结构上就很清晰，用了什么，对应的名称是什么\n注意\n​\t当主环境dev与其他环境有相同属性时，主环境属性生效；其他环境中有相同属性时，最后加载的环境属性生效\n改良\n​\t但是上面的设置也有一个问题，比如我要切换dev环境为pro时，include也要修改。因为include属性只能使用一次，这就比较麻烦了。SpringBoot从2.4版开始使用group属性替代include属性，降低了配置书写量。简单说就是我先写好，你爱用哪个用哪个。\nspring: profiles: active: dev group: \u0026#34;dev\u0026#34;: devDB,devRedis,devMVC \u0026#34;pro\u0026#34;: proDB,proRedis,proMVC \u0026#34;test\u0026#34;: testDB,testRedis,testMVC ​\t现在再来看，如果切换dev到pro，只需要改一下是不是就结束了？完美！\n总结\n多环境开发使用group属性设置配置文件分组，便于线上维护管理 YW-3-5.多环境开发控制\r​\t多环境开发到这里基本上说完了，最后说一个冲突问题。就是maven和SpringBoot同时设置多环境的话怎么搞。\n​\t要想处理这个冲突问题，你要先理清一个关系，究竟谁在多环境开发中其主导地位。也就是说如果现在都设置了多环境，谁的应该是保留下来的，另一个应该遵从相同的设置。\n​\tmaven是做什么的？项目构建管理的，最终生成代码包的，SpringBoot是干什么的？简化开发的。简化，又不是其主导作用。最终还是要靠maven来管理整个工程，所以SpringBoot应该听maven的。整个确认后下面就好做了。大体思想如下：\n先在maven环境中设置用什么具体的环境 在SpringBoot中读取maven设置的环境即可 maven中设置多环境（使用属性方式区分环境）\n\u0026lt;profiles\u0026gt; \u0026lt;profile\u0026gt; \u0026lt;id\u0026gt;env_dev\u0026lt;/id\u0026gt; \u0026lt;properties\u0026gt; \u0026lt;profile.active\u0026gt;dev\u0026lt;/profile.active\u0026gt; \u0026lt;/properties\u0026gt; \u0026lt;activation\u0026gt; \u0026lt;activeByDefault\u0026gt;true\u0026lt;/activeByDefault\u0026gt;\t\u0026lt;!--默认启动环境--\u0026gt; \u0026lt;/activation\u0026gt; \u0026lt;/profile\u0026gt; \u0026lt;profile\u0026gt; \u0026lt;id\u0026gt;env_pro\u0026lt;/id\u0026gt; \u0026lt;properties\u0026gt; \u0026lt;profile.active\u0026gt;pro\u0026lt;/profile.active\u0026gt; \u0026lt;/properties\u0026gt; \u0026lt;/profile\u0026gt; \u0026lt;/profiles\u0026gt; SpringBoot中读取maven设置值\nspring: profiles: active: @profile.active@ ​\t上面的@属性名@就是读取maven中配置的属性值的语法格式。\n总结\n当Maven与SpringBoot同时对多环境进行控制时，以Mavn为主，SpringBoot使用@..@占位符读取Maven对应的配置属性值 基于SpringBoot读取Maven配置属性的前提下，如果在Idea下测试工程时pom.xml每次更新需要手动compile方可生效 YW-4.日志\r​\t运维篇最后一部分我们来聊聊日志，日志大家不陌生，简单介绍一下。日志其实就是记录程序日常运行的信息，主要作用如下：\n编程期调试代码 运营期记录信息 记录日常运营重要信息（峰值流量、平均响应时长……） 记录应用报错信息（错误堆栈） 记录运维过程数据（扩容、宕机、报警……） ​\t或许各位小伙伴并不习惯于使用日志，没关系，慢慢多用，习惯就好。想进大厂，这是最基本的，别去面试的时候说没用过，完了，没机会了。\nYW-4-1.代码中使用日志工具记录日志\r​\t日志的使用格式非常固定，直接上操作步骤：\n步骤①：添加日志记录操作\n@RestController @RequestMapping(\u0026#34;/books\u0026#34;) public class BookController extends BaseClass{ private static final Logger log = LoggerFactory.getLogger(BookController.class); @GetMapping public String getById(){ log.debug(\u0026#34;debug...\u0026#34;); log.info(\u0026#34;info...\u0026#34;); log.warn(\u0026#34;warn...\u0026#34;); log.error(\u0026#34;error...\u0026#34;); return \u0026#34;springboot is running...2\u0026#34;; } } ​\t上述代码中log对象就是用来记录日志的对象，下面的log.debug，log.info这些操作就是写日志的API了。\n步骤②：设置日志输出级别\n​\t日志设置好以后可以根据设置选择哪些参与记录。这里是根据日志的级别来设置的。日志的级别分为6种，分别是：\nTRACE：运行堆栈信息，使用率低 DEBUG：程序员调试代码使用 INFO：记录运维过程数据 WARN：记录运维过程报警数据 ERROR：记录错误堆栈信息 FATAL：灾难信息，合并计入ERROR ​\t一般情况下，开发时候使用DEBUG，上线后使用INFO，运维信息记录使用WARN即可。下面就设置一下日志级别：\n# 开启debug模式，输出调试信息，常用于检查系统运行状况 debug: true ​\t这么设置太简单粗暴了，日志系统通常都提供了细粒度的控制\n# 开启debug模式，输出调试信息，常用于检查系统运行状况 debug: true # 设置日志级别，root表示根节点，即整体应用日志级别 logging: level: root: debug ​\t还可以再设置更细粒度的控制\n步骤③：设置日志组，控制指定包对应的日志输出级别，也可以直接控制指定包对应的日志输出级别\nlogging: # 设置日志组 group: # 自定义组名，设置当前组中所包含的包 ebank: com.itheima.controller level: root: warn # 为对应组设置日志级别 ebank: debug # 为对包设置日志级别 com.itheima.controller: debug ​\t说白了就是总体设置一下，每个包设置一下，如果感觉设置的麻烦，就先把包分个组，对组设置，没了，就这些。\n总结\n日志用于记录开发调试与运维过程消息 日志的级别共6种，通常使用4种即可，分别是DEBUG，INFO,WARN,ERROR 可以通过日志组或代码包的形式进行日志显示级别的控制 教你一招：优化日志对象创建代码\r​\t写代码的时候每个类都要写创建日志记录对象，这个可以优化一下，使用前面用过的lombok技术给我们提供的工具类即可。\n@RestController @RequestMapping(\u0026#34;/books\u0026#34;) public class BookController extends BaseClass{ private static final Logger log = LoggerFactory.getLogger(BookController.class);\t//这一句可以不写了 } ​\t导入lombok后使用注解搞定，日志对象名为log\n@Slf4j\t//这个注解替代了下面那一行 @RestController @RequestMapping(\u0026#34;/books\u0026#34;) public class BookController extends BaseClass{ private static final Logger log = LoggerFactory.getLogger(BookController.class);\t//这一句可以不写了 } 总结\n基于lombok提供的@Slf4j注解为类快速添加日志对象 YW-4-2.日志输出格式控制\r​\t日志已经能够记录了，但是目前记录的格式是SpringBoot给我们提供的，如果想自定义控制就需要自己设置了。先分析一下当前日志的记录格式。\n​\t对于单条日志信息来说，日期，触发位置，记录信息是最核心的信息。级别用于做筛选过滤，PID与线程名用于做精准分析。了解这些信息后就可以DIY日志格式了。本课程不做详细的研究，有兴趣的小伙伴可以学习相关的知识。下面给出课程中模拟的官方日志模板的书写格式，便于大家学习。\nlogging: pattern: console: \u0026#34;%d %clr(%p) --- [%16t] %clr(%-40.40c){cyan} : %m %n\u0026#34; 总结\n日志输出格式设置规则 YW-4-3.日志文件\r​\t日志信息显示，记录已经控制住了，下面就要说一下日志的转存了。日志不能仅显示在控制台上，要把日志记录到文件中，方便后期维护查阅。\n​\t对于日志文件的使用存在各种各样的策略，例如每日记录，分类记录，报警后记录等。这里主要研究日志文件如何记录。\n​\t记录日志到文件中格式非常简单，设置日志文件名即可。\nlogging: file: name: server.log ​\t虽然使用上述格式可以将日志记录下来了，但是面对线上的复杂情况，一个文件记录肯定是不能够满足运维要求的，通常会每天记录日志文件，同时为了便于维护，还要限制每个日志文件的大小。下面给出日志文件的常用配置方式：\nlogging: logback: rollingpolicy: max-file-size: 3KB file-name-pattern: server.%d{yyyy-MM-dd}.%i.log ​\t以上格式是基于logback日志技术设置每日日志文件的设置格式，要求容量到达3KB以后就转存信息到第二个文件中。文件命名规则中的%d标识日期，%i是一个递增变量，用于区分日志文件。\n总结\n日志记录到文件 日志文件格式设置 运维实用篇完结\r​\t运维实用篇到这里就要先告一段落了，为什么不说结束呢？因为运维篇中还有一些知识，但是现在讲解过于分散了。所以要把这些知识与开发实用篇的知识结合在一起讲，也是本课程的教学设计的体现。\n​\t在整体运维实用篇中带着大家学习了4块内容，首先学习了如何运行SpringBoot程序，也就是程序的打包与运行，接下来对配置进行了升级学习，不再局限在配置文件中进行设置，通过临时属性，外部配置文件对项目的配置进行管控。在多环境开发中给大家介绍了多种多环境开发的格式，其实掌握一种即可，此外还给大家讲了多环境开发的一些技巧以及与maven的冲突解决方案。最后给大家介绍了日志系统，老实说日志这里讲的相当的潦草，因为大部分日志相关的知识都不应该在这门课中学习，这里只是告诉大家如何整合实用而已。\n​\t看了各位小伙伴的评论，知道你们再催更，我也在加油，一起努力吧，实用开发篇再会。实用开发篇会提高更新频度，不全部做完给大家更新了，我先把做好的一部分开放出来，随后做完一点就更新一点，额，好吧，就说到这里吧。\nSpringBoot开发实用篇\r​\t怀着忐忑的心情，开始了开发实用篇文档的编写。为什么忐忑？特喵的债欠的太多，不知道从何写起。哎，不煽情了，开工。\n​\t运维实用篇完结以后，开发实用篇采用日更新的形式发布给各位小伙伴，基本上是每天一集，目前已经发布完毕。看评论区，好多小伙伴在求文档，所以赶紧来补文档，加班加点把开发实用篇的文档刨出来。\n​\t开发实用篇中因为牵扯到SpringBoot整合各种各样的技术，由于不是每个小伙伴对各种技术都有所掌握，所以在整合每一个技术之前，都会做一个快速的普及，这样的话内容整个开发实用篇所包含的内容就会比较多。各位小伙伴在学习的时候，如果对某一个技术不是很清楚，可以先跳过对应章节，或者先补充一下技术知识，然后再来看对应的课程。开发实用篇具体包含的内容如下：\n热部署 配置高级 测试 数据层解决方案 整合第三方技术 监控 ​\t看目录感觉内容量并不是很大，但是在数据层解决方案和整合第三方技术中包含了大量的知识，一点一点慢慢学吧。下面开启第一部分热部署相关知识的学习\nKF-1.热部署\r​\t什么是热部署？简单说就是你程序改了，现在要重新启动服务器，嫌麻烦？不用重启，服务器会自己悄悄的把更新后的程序给重新加载一遍，这就是热部署。\n​\t热部署的功能是如何实现的呢？这就要分两种情况来说了，非springboot工程和springboot工程的热部署实现方式完全不一样。先说一下原始的非springboot项目是如何实现热部署的。\n非springboot项目热部署实现原理\n​\t开发非springboot项目时，我们要制作一个web工程并通过tomcat启动，通常需要先安装tomcat服务器到磁盘中，开发的程序配置发布到安装的tomcat服务器上。如果想实现热部署的效果，这种情况其实有两种做法，一种是在tomcat服务器的配置文件中进行配置，这种做法与你使用什么IDE工具无关，不管你使用eclipse还是idea都行。还有一种做法是通过IDE工具进行配置，比如在idea工具中进行设置，这种形式需要依赖IDE工具，每款IDE工具不同，对应的配置也不太一样。但是核心思想是一样的，就是使用服务器去监控其中加载的应用，发现产生了变化就重新加载一次。\n​\t上面所说的非springboot项目实现热部署看上去是一个非常简单的过程，几乎每个小伙伴都能自己写出来。如果你不会写，我给你个最简单的思路，但是实际设计要比这复杂一些。例如启动一个定时任务，任务启动时记录每个文件的大小，以后每5秒比对一下每个文件的大小是否有改变，或者是否有新文件。如果没有改变，放行，如果有改变，刷新当前记录的文件信息，然后重新启动服务器，这就可以实现热部署了。当然，这个过程肯定不能这么做，比如我把一个打印输出的字符串\u0026quot;abc\u0026quot;改成\u0026quot;cba\u0026quot;，比对大小是没有变化的，但是内容缺实变了，所以这么做肯定不行，只是给大家打个比方，而且重启服务器这就是冷启动了，不能算热部署，领会精神吧。\n​\t看上去这个过程也没多复杂，在springboot项目中难道还有其他的弯弯绕吗？还真有。\nspringboot项目热部署实现原理\n​\t基于springboot开发的web工程其实有一个显著的特征，就是tomcat服务器内置了，还记得内嵌服务器吗？服务器是以一个对象的形式在spring容器中运行的。本来我们期望于tomcat服务器加载程序后由tomcat服务器盯着程序，你变化后我就重新启动重新加载，但是现在tomcat和我们的程序是平级的了，都是spring容器中的组件，这下就麻烦了，缺乏了一个直接的管理权，那该怎么做呢？简单，再搞一个程序X在spring容器中盯着你原始开发的程序A不就行了吗？确实，搞一个盯着程序A的程序X就行了，如果你自己开发的程序A变化了，那么程序X就命令tomcat容器重新加载程序A就OK了。并且这样做有一个好处，spring容器中东西不用全部重新加载一遍，只需要重新加载你开发的程序那一部分就可以了，这下效率又高了，挺好。\n​\t下面就说说，怎么搞出来这么一个程序X，肯定不是我们自己手写了，springboot早就做好了，搞一个坐标导入进去就行了。\nKF-1-1.手动启动热部署\r步骤①：导入开发者工具对应的坐标\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-devtools\u0026lt;/artifactId\u0026gt; \u0026lt;optional\u0026gt;true\u0026lt;/optional\u0026gt; \u0026lt;/dependency\u0026gt; 步骤②：构建项目，可以使用快捷键激活此功能\n​\t对应的快捷键一定要记得\n\u0026lt;CTR\u0026gt;L+\u0026lt;F9\u0026gt; ​\t以上过程就实现了springboot工程的热部署，是不是挺简单的。不过这里需要把底层的工作工程给普及一下。\n重启与重载\n​\t一个springboot项目在运行时实际上是分两个过程进行的，根据加载的东西不同，划分成base类加载器与restart类加载器。\nbase类加载器：用来加载jar包中的类，jar包中的类和配置文件由于不会发生变化，因此不管加载多少次，加载的内容不会发生变化 restart类加载器：用来加载开发者自己开发的类、配置文件、页面等信息，这一类文件受开发者影响 ​\t当springboot项目启动时，base类加载器执行，加载jar包中的信息后，restart类加载器执行，加载开发者制作的内容。当执行构建项目后，由于jar中的信息不会变化，因此base类加载器无需再次执行，所以仅仅运行restart类加载即可，也就是将开发者自己制作的内容重新加载就行了，这就完成了一次热部署的过程，也可以说热部署的过程实际上是重新加载restart类加载器中的信息。\n总结\n使用开发者工具可以为当前项目开启热部署功能 使用构建项目操作对工程进行热部署 思考\n​\t上述过程每次进行热部署都需要开发者手工操作，不管是点击按钮还是快捷键都需要开发者手工执行。这种操作的应用场景主要是在开发调试期，并且调试的代码处于不同的文件中，比如服务器启动了，我需要改4个文件中的内容，然后重启，等4个文件都改完了再执行热部署，使用一个快捷键就OK了。但是如果现在开发者要修改的内容就只有一个文件中的少量代码，这个时候代码修改完毕如果能够让程序自己执行热部署功能，就可以减少开发者的操作，也就是自动进行热部署，能这么做吗？是可以的。咱们下一节再说。\n​\nKF-1-2.自动启动热部署\r​\t自动热部署其实就是设计一个开关，打开这个开关后，IDE工具就可以自动热部署。因此这个操作和IDE工具有关，以下以idea为例设置idea中启动热部署\n步骤①：设置自动构建项目\n​\t打开【File】，选择【settings\u0026hellip;】,在面板左侧的菜单中找到【Compile】选项，然后勾选【Build project automatically】，意思是自动构建项目\n​\t自动构建项目选项勾选后\n步骤②：允许在程序运行时进行自动构建\n​\t使用快捷键【Ctrl】+【Alt】+【Shit】+【/】打开维护面板，选择第1项【Registry\u0026hellip;】\n​\t在选项中搜索comple，然后勾选对应项即可\n​\t这样程序在运行的时候就可以进行自动构建了，实现了热部署的效果。\n关注：如果你每敲一个字母，服务器就重新构建一次，这未免有点太频繁了，所以idea设置当idea工具失去焦点5秒后进行热部署。其实就是你从idea工具中切换到其他工具时进行热部署，比如改完程序需要到浏览器上去调试，这个时候idea就自动进行热部署操作。\n总结\n自动热部署要开启自动构建项目 自动热部署要开启在程序运行时自动构建项目 思考\n​\t现在已经实现了热部署了，但是到企业开发的时候你会发现，为了便于管理，在你的程序目录中除了有代码，还有可能有文档，如果你修改了一下文档，这个时候会进行热部署吗？不管是否进行热部署，这个过程我们需要自己控制才比较合理，那这个东西能控制吗？咱们下一节再说。\nKF-1-3.参与热部署监控的文件范围配置\r​\t通过修改项目中的文件，你可以发现其实并不是所有的文件修改都会激活热部署的，原因在于在开发者工具中有一组配置，当满足了配置中的条件后，才会启动热部署，配置中默认不参与热部署的目录信息如下\n/META-INF/maven /META-INF/resources /resources /static /public /templates ​\t以上目录中的文件如果发生变化，是不参与热部署的。如果想修改配置，可以通过application.yml文件进行设定哪些文件不参与热部署操作\nspring: devtools: restart: # 设置不参与热部署的文件或文件夹 exclude: static/**,public/**,config/application.yml 总结\n通过配置可以修改不参与热部署的文件或目录 思考\n​\t热部署功能是一个典型的开发阶段使用的功能，到了线上环境运行程序时，这个功能就没有意义了。能否关闭热部署功能呢？咱们下一节再说。\nKF-1-4.关闭热部署\r​\t线上环境运行时是不可能使用热部署功能的，所以需要强制关闭此功能，通过配置可以关闭此功能。\nspring: devtools: restart: enabled: false ​\t如果当心配置文件层级过多导致相符覆盖最终引起配置失效，可以提高配置的层级，在更高层级中配置关闭热部署。例如在启动容器前通过系统属性设置关闭热部署功能。\n@SpringBootApplication public class SSMPApplication { public static void main(String[] args) { System.setProperty(\u0026#34;spring.devtools.restart.enabled\u0026#34;,\u0026#34;false\u0026#34;); SpringApplication.run(SSMPApplication.class); } } ​\t其实上述担心略微有点多余，因为线上环境的维护是不可能出现修改代码的操作的，这么做唯一的作用是降低资源消耗，毕竟那双盯着你项目是不是产生变化的眼睛只要闭上了，就不具有热部署功能了，这个开关的作用就是禁用对应功能。\n总结\n通过配置可以关闭热部署功能降低线上程序的资源消耗 KF-2.配置高级\r​\t进入开发实用篇第二章内容，配置高级，其实配置在基础篇讲了一部分，在运维实用篇讲了一部分，这里还要讲，讲的东西有什么区别呢？距离开发过程越来越接近，解决的问题也越来越靠近线上环境，下面就开启本章的学习。\nKF-2-1.@ConfigurationProperties\r​\t在基础篇学习了@ConfigurationProperties注解，此注解的作用是用来为bean绑定属性的。开发者可以在yml配置文件中以对象的格式添加若干属性\nservers:\rip-address: 192.168.0.1 port: 2345\rtimeout: -1 ​\t然后再开发一个用来封装数据的实体类，注意要提供属性对应的setter方法\n@Component @Data public class ServerConfig { private String ipAddress; private int port; private long timeout; } ​\t使用@ConfigurationProperties注解就可以将配置中的属性值关联到开发的模型类上\n@Component @Data @ConfigurationProperties(prefix = \u0026#34;servers\u0026#34;) public class ServerConfig { private String ipAddress; private int port; private long timeout; } ​\t这样加载对应bean的时候就可以直接加载配置属性值了。但是目前我们学的都是给自定义的bean使用这种形式加载属性值，如果是第三方的bean呢？能不能用这种形式加载属性值呢？为什么会提出这个疑问？原因就在于当前@ConfigurationProperties注解是写在类定义的上方，而第三方开发的bean源代码不是你自己书写的，你也不可能到源代码中去添加@ConfigurationProperties注解，这种问题该怎么解决呢？下面就来说说这个问题。\n​\t使用@ConfigurationProperties注解其实可以为第三方bean加载属性，格式特殊一点而已。\n步骤①：使用@Bean注解定义第三方bean\n@Bean public DruidDataSource datasource(){ DruidDataSource ds = new DruidDataSource(); return ds; } 步骤②：在yml中定义要绑定的属性，注意datasource此时全小写\ndatasource: driverClassName: com.mysql.jdbc.Driver 步骤③：使用@ConfigurationProperties注解为第三方bean进行属性绑定，注意前缀是全小写的datasource\n@Bean @ConfigurationProperties(prefix = \u0026#34;datasource\u0026#34;) public DruidDataSource datasource(){ DruidDataSource ds = new DruidDataSource(); return ds; } ​\t操作方式完全一样，只不过@ConfigurationProperties注解不仅能添加到类上，还可以添加到方法上，添加到类上是为spring容器管理的当前类的对象绑定属性，添加到方法上是为spring容器管理的当前方法的返回值对象绑定属性，其实本质上都一样。\n​\t做到这其实就出现了一个新的问题，目前我们定义bean不是通过类注解定义就是通过@Bean定义，使用@ConfigurationProperties注解可以为bean进行属性绑定，那在一个业务系统中，哪些bean通过注解@ConfigurationProperties去绑定属性了呢？因为这个注解不仅可以写在类上，还可以写在方法上，所以找起来就比较麻烦了。为了解决这个问题，spring给我们提供了一个全新的注解，专门标注使用@ConfigurationProperties注解绑定属性的bean是哪些。这个注解叫做@EnableConfigurationProperties。具体如何使用呢？\n步骤①：在配置类上开启@EnableConfigurationProperties注解，并标注要使用@ConfigurationProperties注解绑定属性的类\n@SpringBootApplication @EnableConfigurationProperties(ServerConfig.class) public class Springboot13ConfigurationApplication { } 步骤②：在对应的类上直接使用@ConfigurationProperties进行属性绑定\n@Data @ConfigurationProperties(prefix = \u0026#34;servers\u0026#34;) public class ServerConfig { private String ipAddress; private int port; private long timeout; } ​\t有人感觉这没区别啊？注意观察，现在绑定属性的ServerConfig类并没有声明@Component注解。当使用@EnableConfigurationProperties注解时，spring会默认将其标注的类定义为bean，因此无需再次声明@Component注解了。\n​\t最后再说一个小技巧，使用@ConfigurationProperties注解时，会出现一个提示信息\n​\t出现这个提示后只需要添加一个坐标此提醒就消失了\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-configuration-processor\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; 总结\n使用@ConfigurationProperties可以为使用@Bean声明的第三方bean绑定属性 当使用@EnableConfigurationProperties声明进行属性绑定的bean后，无需使用@Component注解再次进行bean声明 KF-2-2.宽松绑定/松散绑定\r​\t在进行属性绑定时，可能会遇到如下情况，为了进行标准命名，开发者会将属性名严格按照驼峰命名法书写，在yml配置文件中将datasource修改为dataSource，如下：\ndataSource: driverClassName: com.mysql.jdbc.Driver ​\t此时程序可以正常运行，然后又将代码中的前缀datasource修改为dataSource，如下：\n@Bean @ConfigurationProperties(prefix = \u0026#34;dataSource\u0026#34;) public DruidDataSource datasource(){ DruidDataSource ds = new DruidDataSource(); return ds; } ​\t此时就发生了编译错误，而且并不是idea工具导致的，运行后依然会出现问题，配置属性名dataSource是无效的\nConfiguration property name \u0026#39;dataSource\u0026#39; is not valid:\rInvalid characters: \u0026#39;S\u0026#39;\rBean: datasource\rReason: Canonical names should be kebab-case (\u0026#39;-\u0026#39; separated), lowercase alpha-numeric characters and must start with a letter\rAction:\rModify \u0026#39;dataSource\u0026#39; so that it conforms to the canonical names requirements. ​\t为什么会出现这种问题，这就要来说一说springboot进行属性绑定时的一个重要知识点了，有关属性名称的宽松绑定，也可以称为宽松绑定。\n​\t什么是宽松绑定？实际上是springboot进行编程时人性化设计的一种体现，即配置文件中的命名格式与变量名的命名格式可以进行格式上的最大化兼容。兼容到什么程度呢？几乎主流的命名格式都支持，例如：\n​\t在ServerConfig中的ipAddress属性名\n@Component @Data @ConfigurationProperties(prefix = \u0026#34;servers\u0026#34;) public class ServerConfig { private String ipAddress; } ​\t可以与下面的配置属性名规则全兼容\nservers:\ripAddress: 192.168.0.2 # 驼峰模式\rip_address: 192.168.0.2 # 下划线模式\rip-address: 192.168.0.2 # 烤肉串模式\rIP_ADDRESS: 192.168.0.2 # 常量模式 ​\t也可以说，以上4种模式最终都可以匹配到ipAddress这个属性名。为什么这样呢？原因就是在进行匹配时，配置中的名称要去掉中划线和下划线后，忽略大小写的情况下去与java代码中的属性名进行忽略大小写的等值匹配，以上4种命名去掉下划线中划线忽略大小写后都是一个词ipaddress，java代码中的属性名忽略大小写后也是ipaddress，这样就可以进行等值匹配了，这就是为什么这4种格式都能匹配成功的原因。不过springboot官方推荐使用烤肉串模式，也就是中划线模式。\n​\t到这里我们掌握了一个知识点，就是命名的规范问题。再来看开始出现的编程错误信息\nConfiguration property name \u0026#39;dataSource\u0026#39; is not valid:\rInvalid characters: \u0026#39;S\u0026#39;\rBean: datasource\rReason: Canonical names should be kebab-case (\u0026#39;-\u0026#39; separated), lowercase alpha-numeric characters and must start with a letter\rAction:\rModify \u0026#39;dataSource\u0026#39; so that it conforms to the canonical names requirements. ​\t其中Reason描述了报错的原因，规范的名称应该是烤肉串(kebab)模式(case)，即使用-分隔，使用小写字母数字作为标准字符，且必须以字母开头。然后再看我们写的名称dataSource，就不满足上述要求。闹了半天，在书写前缀时，这个词不是随意支持的，必须使用上述标准。编程写了这么久，基本上编程习惯都养成了，到这里又被springboot教育了，没辙，谁让人家东西好用呢，按照人家的要求写吧。\n​\t最后说一句，以上规则仅针对springboot中@ConfigurationProperties注解进行属性绑定时有效，对@Value注解进行属性映射无效。有人就说，那我不用你不就行了？不用，你小看springboot的推广能力了，到原理篇我们看源码时，你会发现内部全是这玩意儿，算了，拿人手短吃人嘴短，认怂吧。\n总结\n@ConfigurationProperties绑定属性时支持属性名宽松绑定，这个宽松体现在属性名的命名规则上 @Value注解不支持松散绑定规则 绑定前缀名推荐采用烤肉串命名规则，即使用中划线做分隔符 KF-2-3.常用计量单位绑定\r​\t在前面的配置中，我们书写了如下配置值，其中第三项超时时间timeout描述了服务器操作超时时间，当前值是-1表示永不超时。\nservers:\rip-address: 192.168.0.1 port: 2345\rtimeout: -1 ​\t但是每个人都这个值的理解会产生不同，比如线上服务器完成一次主从备份，配置超时时间240，这个240如果单位是秒就是超时时间4分钟，如果单位是分钟就是超时时间4小时。面对一次线上服务器的主从备份，设置4分钟，简直是开玩笑，别说拷贝过程，备份之前的压缩过程4分钟也搞不定，这个时候问题就来了，怎么解决这个误会？\n​\t除了加强约定之外，springboot充分利用了JDK8中提供的全新的用来表示计量单位的新数据类型，从根本上解决这个问题。以下模型类中添加了两个JDK8中新增的类，分别是Duration和DataSize\n@Component @Data @ConfigurationProperties(prefix = \u0026#34;servers\u0026#34;) public class ServerConfig { @DurationUnit(ChronoUnit.HOURS) private Duration serverTimeOut; @DataSizeUnit(DataUnit.MEGABYTES) private DataSize dataSize; } Duration：表示时间间隔，可以通过@DurationUnit注解描述时间单位，例如上例中描述的单位为小时（ChronoUnit.HOURS）\nDataSize：表示存储空间，可以通过@DataSizeUnit注解描述存储空间单位，例如上例中描述的单位为MB（DataUnit.MEGABYTES）\n​\t使用上述两个单位就可以有效避免因沟通不同步或文档不健全导致的信息不对称问题，从根本上解决了问题，避免产生误读。\nDruation常用单位如下：\nDataSize常用单位如下：\nKF-2-4.校验\r​\t目前我们在进行属性绑定时可以通过松散绑定规则在书写时放飞自我了，但是在书写时由于无法感知模型类中的数据类型，就会出现类型不匹配的问题，比如代码中需要int类型，配置中给了非法的数值，例如写一个“a\u0026quot;，这种数据肯定无法有效的绑定，还会引发错误。\tSpringBoot给出了强大的数据校验功能，可以有效的避免此类问题的发生。在JAVAEE的JSR303规范中给出了具体的数据校验标准，开发者可以根据自己的需要选择对应的校验框架，此处使用Hibernate提供的校验框架来作为实现进行数据校验。书写应用格式非常固定，话不多说，直接上步骤\n步骤①：开启校验框架\n\u0026lt;!--1.导入JSR303规范--\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;javax.validation\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;validation-api\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!--使用hibernate框架提供的校验器做实现--\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.hibernate.validator\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;hibernate-validator\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; 步骤②：在需要开启校验功能的类上使用注解@Validated开启校验功能\n@Component @Data @ConfigurationProperties(prefix = \u0026#34;servers\u0026#34;) //开启对当前bean的属性注入校验 @Validated public class ServerConfig { } 步骤③：对具体的字段设置校验规则\n@Component @Data @ConfigurationProperties(prefix = \u0026#34;servers\u0026#34;) //开启对当前bean的属性注入校验 @Validated public class ServerConfig { //设置具体的规则 @Max(value = 8888,message = \u0026#34;最大值不能超过8888\u0026#34;) @Min(value = 202,message = \u0026#34;最小值不能低于202\u0026#34;) private int port; } ​\t通过设置数据格式校验，就可以有效避免非法数据加载，其实使用起来还是挺轻松的，基本上就是一个格式。\n总结\n开启Bean属性校验功能一共3步：导入JSR303与Hibernate校验框架坐标、使用@Validated注解启用校验功能、使用具体校验规则规范数据校验格式 KF-2-5.数据类型转换\r​\t有关spring属性注入的问题到这里基本上就讲完了，但是最近一名开发者向我咨询了一个问题，我觉得需要给各位学习者分享一下。在学习阶段其实我们遇到的问题往往复杂度比较低，单一性比较强，但是到了线上开发时，都是综合性的问题，而这个开发者遇到的问题就是由于bean的属性注入引发的灾难。\n​\t先把问题描述一下，这位开发者连接数据库正常操作，但是运行程序后显示的信息是密码错误。\njava.sql.SQLException: Access denied for user \u0026#39;root\u0026#39;@\u0026#39;localhost\u0026#39; (using password: YES) ​\t其实看到这个报错，几乎所有的学习者都能分辨出来，这是用户名和密码不匹配，就就是密码输入错了，但是问题就在于密码并没有输入错误，这就比较讨厌了。给的报错信息无法帮助你有效的分析问题，甚至会给你带到沟里。如果是初学者，估计这会心态就崩了，我密码没错啊，你怎么能说我有错误呢？来看看用户名密码的配置是如何写的：\nspring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/ssm_db?serverTimezone=UTC username: root password: 0127 ​\t这名开发者的生日是1月27日，所以密码就使用了0127，其实问题就出在这里了。\n​\t之前在基础篇讲属性注入时，提到过类型相关的知识，在整数相关知识中有这么一句话，支持二进制，八进制，十六进制\n​\t这个问题就处在这里了，因为0127在开发者眼中是一个字符串“0127”，但是在springboot看来，这就是一个数字，而且是一个八进制的数字。当后台使用String类型接收数据时，如果配置文件中配置了一个整数值，他是先安装整数进行处理，读取后再转换成字符串。巧了，0127撞上了八进制的格式，所以最终以十进制数字87的结果存在了。\n​\t这里提两个注意点，第一，字符串标准书写加上引号包裹，养成习惯，第二，遇到0开头的数据多注意吧。\n总结\nyaml文件中对于数字的定义支持进制书写格式，如需使用字符串请使用引号明确标注 KF-3.测试\r​\t说完bean配置相关的内容，下面要对前面讲过的一个知识做加强了，测试。测试是保障程序正确性的唯一屏障，在企业级开发中更是不可缺少，但是由于测试代码往往不产生实际效益，所以一些小型公司并不是很关注，导致一些开发者从小型公司进入中大型公司后，往往这一块比较短板，所以还是要拿出来把这一块知识好好说说，做一名专业的开发人员。\nKF-3-1.加载测试专用属性\r​\t测试过程本身并不是一个复杂的过程，但是很多情况下测试时需要模拟一些线上情况，或者模拟一些特殊情况。如果当前环境按照线上环境已经设定好了，例如是下面的配置\nenv: maxMemory: 32GB minMemory: 16GB ​\t但是你现在想测试对应的兼容性，需要测试如下配置\nenv: maxMemory: 16GB minMemory: 8GB ​\t这个时候我们能不能每次测试的时候都去修改源码application.yml中的配置进行测试呢？显然是不行的。每次测试前改过来，每次测试后改回去，这太麻烦了。于是我们就想，需要在测试环境中创建一组临时属性，去覆盖我们源码中设定的属性，这样测试用例就相当于是一个独立的环境，能够独立测试，这样就方便多了。\n临时属性\n​\tspringboot已经为我们开发者早就想好了这种问题该如何解决，并且提供了对应的功能入口。在测试用例程序中，可以通过对注解@SpringBootTest添加属性来模拟临时属性，具体如下：\n//properties属性可以为当前测试用例添加临时的属性配置 @SpringBootTest(properties = {\u0026#34;test.prop=testValue1\u0026#34;}) public class PropertiesAndArgsTest { @Value(\u0026#34;${test.prop}\u0026#34;) private String msg; @Test void testProperties(){ System.out.println(msg); } } ​\t使用注解@SpringBootTest的properties属性就可以为当前测试用例添加临时的属性，覆盖源码配置文件中对应的属性值进行测试。\n临时参数\n​\t除了上述这种情况，在前面讲解使用命令行启动springboot程序时讲过，通过命令行参数也可以设置属性值。而且线上启动程序时，通常都会添加一些专用的配置信息。作为运维人员他们才不懂java，更不懂这些配置的信息具体格式该怎么写，那如果我们作为开发者提供了对应的书写内容后，能否提前测试一下这些配置信息是否有效呢？当时是可以的，还是通过注解@SpringBootTest的另一个属性来进行设定。\n//args属性可以为当前测试用例添加临时的命令行参数 @SpringBootTest(args={\u0026#34;--test.prop=testValue2\u0026#34;}) public class PropertiesAndArgsTest { @Value(\u0026#34;${test.prop}\u0026#34;) private String msg; @Test void testProperties(){ System.out.println(msg); } } ​\t使用注解@SpringBootTest的args属性就可以为当前测试用例模拟命令行参数并进行测试。\n​\t说到这里，好奇宝宝们肯定就有新问题了，如果两者共存呢？其实如果思考一下配置属性与命令行参数的加载优先级，这个结果就不言而喻了。在属性加载的优先级设定中，有明确的优先级设定顺序，还记得下面这个顺序吗？\n​\t在这个属性加载优先级的顺序中，明确规定了命令行参数的优先级排序是11，而配置属性的优先级是3，结果不言而喻了，args属性配置优先于properties属性配置加载。\n​\t到这里我们就掌握了如果在测试用例中去模拟临时属性的设定。\n总结\n加载测试临时属性可以通过注解@SpringBootTest的properties和args属性进行设定，此设定应用范围仅适用于当前测试用例 思考\n​\t应用于测试环境的临时属性解决了，如果想在测试的时候临时加载一些bean能不做呢？也就是说我测试时，想搞一些独立的bean出来，专门应用于测试环境，能否实现呢？咱们下一节再讲。\nKF-3-2.加载测试专用配置\r​\t上一节提出了临时配置一些专用于测试环境的bean的需求，这一节我们就来解决这个问题。\n​\t学习过Spring的知识，我们都知道，其实一个spring环境中可以设置若干个配置文件或配置类，若干个配置信息可以同时生效。现在我们的需求就是在测试环境中再添加一个配置类，然后启动测试环境时，生效此配置就行了。其实做法和spring环境中加载多个配置信息的方式完全一样。具体操作步骤如下：\n步骤①：在测试包test中创建专用的测试环境配置类\n@Configuration public class MsgConfig { @Bean public String msg(){ return \u0026#34;bean msg\u0026#34;; } } ​\t上述配置仅用于演示当前实验效果，实际开发可不能这么注入String类型的数据\n步骤②：在启动测试环境时，导入测试环境专用的配置类，使用@Import注解即可实现\n@SpringBootTest @Import({MsgConfig.class}) public class ConfigurationTest { @Autowired private String msg; @Test void testConfiguration(){ System.out.println(msg); } } ​\t到这里就通过@Import属性实现了基于开发环境的配置基础上，对配置进行测试环境的追加操作，实现了1+1的配置环境效果。这样我们就可以实现每一个不同的测试用例加载不同的bean的效果，丰富测试用例的编写，同时不影响开发环境的配置。\n总结\n定义测试环境专用的配置类，然后通过@Import注解在具体的测试中导入临时的配置，例如测试用例，方便测试过程，且上述配置不影响其他的测试类环境 思考\n​\t当前我们已经可以实现业务层和数据层的测试，并且通过临时配置，控制每个测试用例加载不同的测试数据。但是实际企业开发不仅要保障业务层与数据层的功能安全有效，也要保障表现层的功能正常。但是我们目的对表现层的测试都是通过postman手工测试的，并没有在打包过程中体现表现层功能被测试通过。能否在测试用例中对表现层进行功能测试呢？还真可以，咱们下一节再讲。\nKF-3-3.Web环境模拟测试\r​\t在测试中对表现层功能进行测试需要一个基础和一个功能。所谓的一个基础是运行测试程序时，必须启动web环境，不然没法测试web功能。一个功能是必须在测试程序中具备发送web请求的能力，不然无法实现web功能的测试。所以在测试用例中测试表现层接口这项工作就转换成了两件事，一，如何在测试类中启动web测试，二，如何在测试类中发送web请求。下面一件事一件事进行，先说第一个\n测试类中启动web环境\n​\t每一个springboot的测试类上方都会标准@SpringBootTest注解，而注解带有一个属性，叫做webEnvironment。通过该属性就可以设置在测试用例中启动web环境，具体如下：\n@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) public class WebTest {\t} ​\t测试类中启动web环境时，可以指定启动的Web环境对应的端口，springboot提供了4种设置值，分别如下：\nMOCK：根据当前设置确认是否启动web环境，例如使用了Servlet的API就启动web环境，属于适配性的配置 DEFINED_PORT：使用自定义的端口作为web服务器端口 RANDOM_PORT：使用随机端口作为web服务器端口 NONE：不启动web环境 ​\t通过上述配置，现在启动测试程序时就可以正常启用web环境了，建议大家测试时使用RANDOM_PORT，避免代码中因为写死设定引发线上功能打包测试时由于端口冲突导致意外现象的出现。就是说你程序中写了用8080端口，结果线上环境8080端口被占用了，结果你代码中所有写的东西都要改，这就是写死代码的代价。现在你用随机端口就可以测试出来你有没有这种问题的隐患了。\n​\t测试环境中的web环境已经搭建好了，下面就可以来解决第二个问题了，如何在程序代码中发送web请求。\n测试类中发送请求\n​\t对于测试类中发送请求，其实java的API就提供对应的功能，只不过平时各位小伙伴接触的比较少，所以较为陌生。springboot为了便于开发者进行对应的功能开发，对其又进行了包装，简化了开发步骤，具体操作如下：\n步骤①：在测试类中开启web虚拟调用功能，通过注解@AutoConfigureMockMvc实现此功能的开启\n@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) //开启虚拟MVC调用 @AutoConfigureMockMvc public class WebTest { } 步骤②：定义发起虚拟调用的对象MockMVC，通过自动装配的形式初始化对象\n@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) //开启虚拟MVC调用 @AutoConfigureMockMvc public class WebTest { @Test void testWeb(@Autowired MockMvc mvc) { } } 步骤③：创建一个虚拟请求对象，封装请求的路径，并使用MockMVC对象发送对应请求\n@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) //开启虚拟MVC调用 @AutoConfigureMockMvc public class WebTest { @Test void testWeb(@Autowired MockMvc mvc) throws Exception { //http://localhost:8080/books //创建虚拟请求，当前访问/books MockHttpServletRequestBuilder builder = MockMvcRequestBuilders.get(\u0026#34;/books\u0026#34;); //执行对应的请求 mvc.perform(builder); } } ​\t执行测试程序，现在就可以正常的发送/books对应的请求了，注意访问路径不要写http://localhost:8080/books，因为前面的服务器IP地址和端口使用的是当前虚拟的web环境，无需指定，仅指定请求的具体路径即可。\n总结\n在测试类中测试web层接口要保障测试类启动时启动web容器，使用@SpringBootTest注解的webEnvironment属性可以虚拟web环境用于测试 为测试方法注入MockMvc对象，通过MockMvc对象可以发送虚拟请求，模拟web请求调用过程 思考\n​\t目前已经成功的发送了请求，但是还没有起到测试的效果，测试过程必须出现预计值与真实值的比对结果才能确认测试结果是否通过，虚拟请求中能对哪些请求结果进行比对呢？咱们下一节再讲。\nweb环境请求结果比对\n​\t上一节已经在测试用例中成功的模拟出了web环境，并成功的发送了web请求，本节就来解决发送请求后如何比对发送结果的问题。其实发完请求得到的信息只有一种，就是响应对象。至于响应对象中包含什么，就可以比对什么。常见的比对内容如下：\n响应状态匹配\n@Test void testStatus(@Autowired MockMvc mvc) throws Exception { MockHttpServletRequestBuilder builder = MockMvcRequestBuilders.get(\u0026#34;/books\u0026#34;); ResultActions action = mvc.perform(builder); //设定预期值 与真实值进行比较，成功测试通过，失败测试失败 //定义本次调用的预期值 StatusResultMatchers status = MockMvcResultMatchers.status(); //预计本次调用时成功的：状态200 ResultMatcher ok = status.isOk(); //添加预计值到本次调用过程中进行匹配 action.andExpect(ok); } 响应体匹配（非json数据格式）\n@Test void testBody(@Autowired MockMvc mvc) throws Exception { MockHttpServletRequestBuilder builder = MockMvcRequestBuilders.get(\u0026#34;/books\u0026#34;); ResultActions action = mvc.perform(builder); //设定预期值 与真实值进行比较，成功测试通过，失败测试失败 //定义本次调用的预期值 ContentResultMatchers content = MockMvcResultMatchers.content(); ResultMatcher result = content.string(\u0026#34;springboot2\u0026#34;); //添加预计值到本次调用过程中进行匹配 action.andExpect(result); } 响应体匹配（json数据格式，开发中的主流使用方式）\n@Test void testJson(@Autowired MockMvc mvc) throws Exception { MockHttpServletRequestBuilder builder = MockMvcRequestBuilders.get(\u0026#34;/books\u0026#34;); ResultActions action = mvc.perform(builder); //设定预期值 与真实值进行比较，成功测试通过，失败测试失败 //定义本次调用的预期值 ContentResultMatchers content = MockMvcResultMatchers.content(); ResultMatcher result = content.json(\u0026#34;{\\\u0026#34;id\\\u0026#34;:1,\\\u0026#34;name\\\u0026#34;:\\\u0026#34;springboot2\\\u0026#34;,\\\u0026#34;type\\\u0026#34;:\\\u0026#34;springboot\\\u0026#34;}\u0026#34;); //添加预计值到本次调用过程中进行匹配 action.andExpect(result); } 响应头信息匹配\n@Test void testContentType(@Autowired MockMvc mvc) throws Exception { MockHttpServletRequestBuilder builder = MockMvcRequestBuilders.get(\u0026#34;/books\u0026#34;); ResultActions action = mvc.perform(builder); //设定预期值 与真实值进行比较，成功测试通过，失败测试失败 //定义本次调用的预期值 HeaderResultMatchers header = MockMvcResultMatchers.header(); ResultMatcher contentType = header.string(\u0026#34;Content-Type\u0026#34;, \u0026#34;application/json\u0026#34;); //添加预计值到本次调用过程中进行匹配 action.andExpect(contentType); } ​\t基本上齐了，头信息，正文信息，状态信息都有了，就可以组合出一个完美的响应结果比对结果了。以下范例就是三种信息同时进行匹配校验，也是一个完整的信息匹配过程。\n@Test void testGetById(@Autowired MockMvc mvc) throws Exception { MockHttpServletRequestBuilder builder = MockMvcRequestBuilders.get(\u0026#34;/books\u0026#34;); ResultActions action = mvc.perform(builder); StatusResultMatchers status = MockMvcResultMatchers.status(); ResultMatcher ok = status.isOk(); action.andExpect(ok); HeaderResultMatchers header = MockMvcResultMatchers.header(); ResultMatcher contentType = header.string(\u0026#34;Content-Type\u0026#34;, \u0026#34;application/json\u0026#34;); action.andExpect(contentType); ContentResultMatchers content = MockMvcResultMatchers.content(); ResultMatcher result = content.json(\u0026#34;{\\\u0026#34;id\\\u0026#34;:1,\\\u0026#34;name\\\u0026#34;:\\\u0026#34;springboot\\\u0026#34;,\\\u0026#34;type\\\u0026#34;:\\\u0026#34;springboot\\\u0026#34;}\u0026#34;); action.andExpect(result); } 总结\nweb虚拟调用可以对本地虚拟请求的返回响应信息进行比对，分为响应头信息比对、响应体信息比对、响应状态信息比对 KF-3-4.数据层测试回滚\r​\t当前我们的测试程序可以完美的进行表现层、业务层、数据层接口对应的功能测试了，但是测试用例开发完成后，在打包的阶段由于test生命周期属于必须被运行的生命周期，如果跳过会给系统带来极高的安全隐患，所以测试用例必须执行。但是新的问题就呈现了，测试用例如果测试时产生了事务提交就会在测试过程中对数据库数据产生影响，进而产生垃圾数据。这个过程不是我们希望发生的，作为开发者测试用例该运行运行，但是过程中产生的数据不要在我的系统中留痕，这样该如何处理呢？\n​\tspringboot早就为开发者想到了这个问题，并且针对此问题给出了最简解决方案，在原始测试用例中添加注解@Transactional即可实现当前测试用例的事务不提交。当程序运行后，只要注解@Transactional出现的位置存在注解@SpringBootTest，springboot就会认为这是一个测试程序，无需提交事务，所以也就可以避免事务的提交。\n@SpringBootTest @Transactional @Rollback(true) public class DaoTest { @Autowired private BookService bookService; @Test void testSave(){ Book book = new Book(); book.setName(\u0026#34;springboot3\u0026#34;); book.setType(\u0026#34;springboot3\u0026#34;); book.setDescription(\u0026#34;springboot3\u0026#34;); bookService.save(book); } } ​\t如果开发者想提交事务，也可以，再添加一个@RollBack的注解，设置回滚状态为false即可正常提交事务，是不是很方便？springboot在辅助开发者日常工作这一块展现出了惊人的能力，实在太贴心了。\n总结\n在springboot的测试类中通过添加注解@Transactional来阻止测试用例提交事务 通过注解@Rollback控制springboot测试类执行结果是否提交事务，需要配合注解@Transactional使用 思考\n​\t当前测试程序已经近乎完美了，但是由于测试用例中书写的测试数据属于固定数据，往往失去了测试的意义，开发者可以针对测试用例进行针对性开发，这样就有可能出现测试用例不能完美呈现业务逻辑代码是否真实有效的达成业务目标的现象，解决方案其实很容易想，测试用例的数据只要随机产生就可以了，能实现吗？咱们下一节再讲。\nKF-3-5.测试用例数据设定\r​\t对于测试用例的数据固定书写肯定是不合理的，springboot提供了在配置中使用随机值的机制，确保每次运行程序加载的数据都是随机的。具体如下：\ntestcase: book: id: ${random.int} id2: ${random.int(10)} type: ${random.int!5,10!} name: ${random.value} uuid: ${random.uuid} publishTime: ${random.long} ​\t当前配置就可以在每次运行程序时创建一组随机数据，避免每次运行时数据都是固定值的尴尬现象发生，有助于测试功能的进行。数据的加载按照之前加载数据的形式，使用@ConfigurationProperties注解即可\n@Component @Data @ConfigurationProperties(prefix = \u0026#34;testcase.book\u0026#34;) public class BookCase { private int id; private int id2; private int type; private String name; private String uuid; private long publishTime; } ​\t对于随机值的产生，还有一些小的限定规则，比如产生的数值性数据可以设置范围等，具体如下：\n${random.int}表示随机整数 ${random.int(10)}表示10以内的随机数 ${random.int(10,20)}表示10到20的随机数 其中()可以是任意字符，例如[]，!!均可 总结\n使用随机数据可以替换测试用例中书写的固定数据，提高测试用例中的测试数据有效性 KF-4.数据层解决方案\r​\t开发实用篇前三章基本上是开胃菜，从第四章开始，开发实用篇进入到了噩梦难度了，从这里开始，不再是单纯的在springboot内部搞事情了，要涉及到很多相关知识。本章节主要内容都是和数据存储与读取相关，前期学习的知识与数据层有关的技术基本上都围绕在数据库这个层面上，所以本章要讲的第一个大的分支就是SQL解决方案相关的内容，除此之外，数据的来源还可以是非SQL技术相关的数据操作，因此第二部分围绕着NOSQL解决方案讲解。至于什么是NOSQL解决方案，讲到了再说吧。下面就从SQL解决方案说起。\nKF-4-1.SQL\r​\t回忆一下之前做SSMP整合的时候数据层解决方案涉及到了哪些技术？MySQL数据库与MyBatisPlus框架，后面又学了Druid数据源的配置，所以现在数据层解决方案可以说是Mysql+Druid+MyBatisPlus。而三个技术分别对应了数据层操作的三个层面：\n数据源技术：Druid 持久化技术：MyBatisPlus 数据库技术：MySQL ​\t下面的研究就分为三个层面进行研究，对应上面列出的三个方面，咱们就从第一个数据源技术开始说起。\n数据源技术\r​\t目前我们使用的数据源技术是Druid，运行时可以在日志中看到对应的数据源初始化信息，具体如下：\nINFO 28600 --- [ main] c.a.d.s.b.a.DruidDataSourceAutoConfigure : Init DruidDataSource\rINFO 28600 --- [ main] com.alibaba.druid.pool.DruidDataSource : {dataSource-1} inited ​\t如果不使用Druid数据源，程序运行后是什么样子呢？是独立的数据库连接对象还是有其他的连接池技术支持呢？将Druid技术对应的starter去掉再次运行程序可以在日志中找到如下初始化信息：\nINFO 31820 --- [ main] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Starting...\rINFO 31820 --- [ main] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Start completed. ​\t虽然没有DruidDataSource相关的信息了，但是我们发现日志中有HikariDataSource这个信息，就算不懂这是个什么技术，看名字也能看出来，以DataSource结尾的名称，这一定是一个数据源技术。我们又没有手工添加这个技术，这个技术哪里来的呢？这就是这一节要讲的知识，springboot内嵌数据源。\n​\t数据层技术是每一个企业级应用程序都会用到的，而其中必定会进行数据库连接的管理。springboot根据开发者的习惯出发，开发者提供了数据源技术，就用你提供的，开发者没有提供，那总不能手工管理一个一个的数据库连接对象啊，怎么办？我给你一个默认的就好了，这样省心又省事，大家都方便。\n​\tspringboot提供了3款内嵌数据源技术，分别如下：\nHikariCP Tomcat提供DataSource Commons DBCP ​\t第一种，HikartCP，这是springboot官方推荐的数据源技术，作为默认内置数据源使用。啥意思？你不配置数据源，那就用这个。\n​\t第二种，Tomcat提供的DataSource，如果不想用HikartCP，并且使用tomcat作为web服务器进行web程序的开发，使用这个。为什么是Tomcat，不是其他web服务器呢？因为web技术导入starter后，默认使用内嵌tomcat，既然都是默认使用的技术了，那就一用到底，数据源也用它的。有人就提出怎么才能不使用HikartCP用tomcat提供的默认数据源对象呢？把HikartCP技术的坐标排除掉就OK了。\n​\t第三种，DBCP，这个使用的条件就更苛刻了，既不使用HikartCP也不使用tomcat的DataSource时，默认给你用这个。\n​\tspringboot这心操的，也是稀碎啊，就怕你自己管不好连接对象，给你一顿推荐，真是开发界的最强辅助。既然都给你奶上了，那就受用吧，怎么配置使用这些东西呢？之前我们配置druid时使用druid的starter对应的配置如下：\nspring: datasource: druid:\turl: jdbc:mysql://localhost:3306/ssm_db?serverTimezone=UTC driver-class-name: com.mysql.cj.jdbc.Driver username: root password: root ​\t换成是默认的数据源HikariCP后，直接吧druid删掉就行了，如下：\nspring: datasource: url: jdbc:mysql://localhost:3306/ssm_db?serverTimezone=UTC driver-class-name: com.mysql.cj.jdbc.Driver username: root password: root ​\t当然，也可以写上是对hikari做的配置，但是url地址要单独配置，如下：\nspring: datasource: url: jdbc:mysql://localhost:3306/ssm_db?serverTimezone=UTC hikari: driver-class-name: com.mysql.cj.jdbc.Driver username: root password: root ​\t这就是配置hikari数据源的方式。如果想对hikari做进一步的配置，可以继续配置其独立的属性。例如：\nspring: datasource: url: jdbc:mysql://localhost:3306/ssm_db?serverTimezone=UTC hikari: driver-class-name: com.mysql.cj.jdbc.Driver username: root password: root maximum-pool-size: 50 ​\t如果不想使用hikari数据源，使用tomcat的数据源或者DBCP配置格式也是一样的。学习到这里，以后我们做数据层时，数据源对象的选择就不再是单一的使用druid数据源技术了，可以根据需要自行选择。\n总结\nspringboot技术提供了3种内置的数据源技术，分别是Hikari、tomcat内置数据源、DBCP 持久化技术\r​\t说完数据源解决方案，再来说一下持久化解决方案。springboot充分发挥其最强辅助的特征，给开发者提供了一套现成的数据层技术，叫做JdbcTemplate。其实这个技术不能说是springboot提供的，因为不使用springboot技术，一样能使用它，谁提供的呢？spring技术提供的，所以在springboot技术范畴中，这个技术也是存在的，毕竟springboot技术是加速spring程序开发而创建的。\n​\t这个技术其实就是回归到jdbc最原始的编程形式来进行数据层的开发，下面直接上操作步骤：\n步骤①：导入jdbc对应的坐标，记得是starter\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-jdbc\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency 步骤②：自动装配JdbcTemplate对象\n@SpringBootTest class Springboot15SqlApplicationTests { @Test void testJdbcTemplate(@Autowired JdbcTemplate jdbcTemplate){ } } 步骤③：使用JdbcTemplate实现查询操作（非实体类封装数据的查询操作）\n@Test void testJdbcTemplate(@Autowired JdbcTemplate jdbcTemplate){ String sql = \u0026#34;select * from tbl_book\u0026#34;; List\u0026lt;Map\u0026lt;String, Object\u0026gt;\u0026gt; maps = jdbcTemplate.queryForList(sql); System.out.println(maps); } 步骤④：使用JdbcTemplate实现查询操作（实体类封装数据的查询操作）\n@Test void testJdbcTemplate(@Autowired JdbcTemplate jdbcTemplate){ String sql = \u0026#34;select * from tbl_book\u0026#34;; RowMapper\u0026lt;Book\u0026gt; rm = new RowMapper\u0026lt;Book\u0026gt;() { @Override public Book mapRow(ResultSet rs, int rowNum) throws SQLException { Book temp = new Book(); temp.setId(rs.getInt(\u0026#34;id\u0026#34;)); temp.setName(rs.getString(\u0026#34;name\u0026#34;)); temp.setType(rs.getString(\u0026#34;type\u0026#34;)); temp.setDescription(rs.getString(\u0026#34;description\u0026#34;)); return temp; } }; List\u0026lt;Book\u0026gt; list = jdbcTemplate.query(sql, rm); System.out.println(list); } 步骤⑤：使用JdbcTemplate实现增删改操作\n@Test void testJdbcTemplateSave(@Autowired JdbcTemplate jdbcTemplate){ String sql = \u0026#34;insert into tbl_book values(3,\u0026#39;springboot1\u0026#39;,\u0026#39;springboot2\u0026#39;,\u0026#39;springboot3\u0026#39;)\u0026#34;; jdbcTemplate.update(sql); } ​\t如果想对JdbcTemplate对象进行相关配置，可以在yml文件中进行设定，具体如下：\nspring: jdbc: template: query-timeout: -1 # 查询超时时间 max-rows: 500 # 最大行数 fetch-size: -1 # 缓存行数 总结\nSpringBoot内置JdbcTemplate持久化解决方案 使用JdbcTemplate需要导入spring-boot-starter-jdbc的坐标 数据库技术\r​\t截止到目前，springboot给开发者提供了内置的数据源解决方案和持久化解决方案，在数据层解决方案三件套中还剩下一个数据库，莫非springboot也提供有内置的解决方案？还真有，还不是一个，三个，这一节就来说说内置的数据库解决方案。\n​\tspringboot提供了3款内置的数据库，分别是\nH2 HSQL Derby ​\t以上三款数据库除了可以独立安装之外，还可以像是tomcat服务器一样，采用内嵌的形式运行在spirngboot容器中。内嵌在容器中运行，那必须是java对象啊，对，这三款数据库底层都是使用java语言开发的。\n​\t我们一直使用MySQL数据库就挺好的，为什么有需求用这个呢？原因就在于这三个数据库都可以采用内嵌容器的形式运行，在应用程序运行后，如果我们进行测试工作，此时测试的数据无需存储在磁盘上，但是又要测试使用，内嵌数据库就方便了，运行在内存中，该测试测试，该运行运行，等服务器关闭后，一切烟消云散，多好，省得你维护外部数据库了。这也是内嵌数据库的最大优点，方便进行功能测试。\n​\t下面以H2数据库为例讲解如何使用这些内嵌数据库，操作步骤也非常简单，简单才好用嘛\n步骤①：导入H2数据库对应的坐标，一共2个\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.h2database\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;h2\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-data-jpa\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; 步骤②：将工程设置为web工程，启动工程时启动H2数据库\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-web\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; 步骤③：通过配置开启H2数据库控制台访问程序，也可以使用其他的数据库连接软件操作\nspring: h2: console: enabled: true path: /h2 ​\tweb端访问路径/h2，访问密码123456，如果访问失败，先配置下列数据源，启动程序运行后再次访问/h2路径就可以正常访问了\ndatasource: url: jdbc:h2:~/test hikari: driver-class-name: org.h2.Driver username: sa password: 123456 步骤④：使用JdbcTemplate或MyBatisPlus技术操作数据库\n（略）\n​\t其实我们只是换了一个数据库而已，其他的东西都不受影响。一个重要提醒，别忘了，上线时，把内存级数据库关闭，采用MySQL数据库作为数据持久化方案，关闭方式就是设置enabled属性为false即可。\n总结\nH2内嵌式数据库启动方式，添加坐标，添加配置 H2数据库线上运行时请务必关闭 ​\t到这里SQL相关的数据层解决方案就讲完了，现在的可选技术就丰富的多了。\n数据源技术：Druid、Hikari、tomcat DataSource、DBCP 持久化技术：MyBatisPlus、MyBatis、JdbcTemplate 数据库技术：MySQL、H2、HSQL、Derby ​\t现在开发程序时就可以在以上技术中任选一种组织成一套数据库解决方案了。\nKF-4-2.NoSQL\r​\tSQL数据层解决方案说完了，下面来说收NoSQL数据层解决方案。这个NoSQL是什么意思呢？从字面来看，No表示否定，NoSQL就是非关系型数据库解决方案，意思就是数据该存存该取取，只是这些数据不放在关系型数据库中了，那放在哪里？自然是一些能够存储数据的其他相关技术中了，比如Redis等。本节讲解的内容就是springboot如何整合这些技术，在springboot官方文档中提供了10种相关技术的整合方案，我们将讲解国内市场上最流行的几款NoSQL数据库整合方案，分别是Redis、MongoDB、ES。\n​\t因为每个小伙伴学习这门课程的时候起点不同，为了便于各位学习者更好的学习，每种技术在讲解整合前都会先讲一下安装和基本使用，然后再讲整合。如果对某个技术比较熟悉的小伙伴可以直接跳过安装的学习过程，直接看整合方案即可。此外上述这些技术最佳使用方案都是在Linux服务器上部署，但是考虑到各位小伙伴的学习起点差异过大，所以下面的课程都是以Windows平台作为安装基础讲解，如果想看Linux版软件安装，可以再找到对应技术的学习文档查阅学习。\nSpringBoot整合Redis\r​\tRedis是一款采用key-value数据存储格式的内存级NoSQL数据库，重点关注数据存储格式，是key-value格式，也就是键值对的存储形式。与MySQL数据库不同，MySQL数据库有表、有字段、有记录，Redis没有这些东西，就是一个名称对应一个值，并且数据以存储在内存中使用为主。什么叫以存储在内存中为主？其实Redis有它的数据持久化方案，分别是RDB和AOF，但是Redis自身并不是为了数据持久化而生的，主要是在内存中保存数据，加速数据访问的，所以说是一款内存级数据库。\n​\tRedis支持多种数据存储格式，比如可以直接存字符串，也可以存一个map集合，list集合，后面会涉及到一些不同格式的数据操作，这个需要先学习一下才能进行整合，所以在基本操作中会介绍一些相关操作。下面就先安装，再操作，最后说整合\n安装\r​\twindows版安装包下载地址：https://github.com/tporadowski/redis/releases\n​\t下载的安装包有两种形式，一种是一键安装的msi文件，还有一种是解压缩就能使用的zip文件，哪种形式都行，这里就不介绍安装过程了，本课程采用的是msi一键安装的msi文件进行安装的。\n​\t啥是msi，其实就是一个文件安装包，不仅安装软件，还帮你把安装软件时需要的功能关联在一起，打包操作。比如如安装序列、创建和设置安装路径、设置系统依赖项、默认设定安装选项和控制安装过程的属性。说简单点就是一站式服务，安装过程一条龙操作一气呵成，就是为小白用户提供的软件安装程序。\n​\t安装完毕后会得到如下文件，其中有两个文件对应两个命令，是启动Redis的核心命令，需要再CMD命令行模式执行。\n启动服务器\nredis-server.exe redis.windows.conf ​\t初学者无需调整服务器对外服务端口，默认6379。\n启动客户端\nredis-cli.exe ​\t如果启动redis服务器失败，可以先启动客户端，然后执行shutdown操作后退出，此时redis服务器就可以正常执行了。\n基本操作\r​\t服务器启动后，使用客户端就可以连接服务器，类似于启动完MySQL数据库，然后启动SQL命令行操作数据库。\n​\t放置一个字符串数据到redis中，先为数据定义一个名称，比如name,age等，然后使用命令set设置数据到redis服务器中即可\nset name itheima\rset age 12 ​\t从redis中取出已经放入的数据，根据名称取，就可以得到对应数据。如果没有对应数据就会得到(nil)\nget name\rget age ​\t以上使用的数据存储是一个名称对应一个值，如果要维护的数据过多，可以使用别的数据存储结构。例如hash，它是一种一个名称下可以存储多个数据的存储模型，并且每个数据也可以有自己的二级存储名称。向hash结构中存储数据格式如下：\nhset a a1 aa1\t#对外key名称是a，在名称为a的存储模型中，a1这个key中保存了数据aa1\rhset a a2 aa2 ​\t获取hash结构中的数据命令如下\nhget a a1\t#得到aa1\rhget a a2\t#得到aa2 ​\t有关redis的基础操作就普及到这里，需要全面掌握redis技术，请参看相关教程学习。\n整合\r​\t在进行整合之前先梳理一下整合的思想，springboot整合任何技术其实就是在springboot中使用对应技术的API。如果两个技术没有交集，就不存在整合的概念了。所谓整合其实就是使用springboot技术去管理其他技术，几个问题是躲不掉的。\n​\t第一，需要先导入对应技术的坐标，而整合之后，这些坐标都有了一些变化\n​\t第二，任何技术通常都会有一些相关的设置信息，整合之后，这些信息如何写，写在哪是一个问题\n​\t第三，没有整合之前操作如果是模式A的话，整合之后如果没有给开发者带来一些便捷操作，那整合将毫无意义，所以整合后操作肯定要简化一些，那对应的操作方式自然也有所不同\n​\t按照上面的三个问题去思考springboot整合所有技术是一种通用思想，在整合的过程中会逐步摸索出整合的套路，而且适用性非常强，经过若干种技术的整合后基本上可以总结出一套固定思维。\n​\t下面就开始springboot整合redis，操作步骤如下：\n步骤①：导入springboot整合redis的starter坐标\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-data-redis\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; ​\t上述坐标可以在创建模块的时候通过勾选的形式进行选择，归属NoSQL分类中\n步骤②：进行基础配置\nspring: redis: host: localhost port: 6379 ​\t操作redis，最基本的信息就是操作哪一台redis服务器，所以服务器地址属于基础配置信息，不可缺少。但是即便你不配置，目前也是可以用的。因为以上两组信息都有默认配置，刚好就是上述配置值。\n步骤③：使用springboot整合redis的专用客户端接口操作，此处使用的是RedisTemplate\n@SpringBootTest class Springboot16RedisApplicationTests { @Autowired private RedisTemplate redisTemplate; @Test void set() { ValueOperations ops = redisTemplate.opsForValue(); ops.set(\u0026#34;age\u0026#34;,41); } @Test void get() { ValueOperations ops = redisTemplate.opsForValue(); Object age = ops.get(\u0026#34;name\u0026#34;); System.out.println(age); } @Test void hset() { HashOperations ops = redisTemplate.opsForHash(); ops.put(\u0026#34;info\u0026#34;,\u0026#34;b\u0026#34;,\u0026#34;bb\u0026#34;); } @Test void hget() { HashOperations ops = redisTemplate.opsForHash(); Object val = ops.get(\u0026#34;info\u0026#34;, \u0026#34;b\u0026#34;); System.out.println(val); } } ​\t在操作redis时，需要先确认操作何种数据，根据数据种类得到操作接口。例如使用opsForValue()获取string类型的数据操作接口，使用opsForHash()获取hash类型的数据操作接口，剩下的就是调用对应api操作了。各种类型的数据操作接口如下：\n总结\nspringboot整合redis步骤 导入springboot整合redis的starter坐标 进行基础配置 使用springboot整合redis的专用客户端接口RedisTemplate操作 StringRedisTemplate\n​\t由于redis内部不提供java对象的存储格式，因此当操作的数据以对象的形式存在时，会进行转码，转换成字符串格式后进行操作。为了方便开发者使用基于字符串为数据的操作，springboot整合redis时提供了专用的API接口StringRedisTemplate，你可以理解为这是RedisTemplate的一种指定数据泛型的操作API。\n@SpringBootTest public class StringRedisTemplateTest { @Autowired private StringRedisTemplate stringRedisTemplate; @Test void get(){ ValueOperations\u0026lt;String, String\u0026gt; ops = stringRedisTemplate.opsForValue(); String name = ops.get(\u0026#34;name\u0026#34;); System.out.println(name); } } redis客户端选择\nspringboot整合redis技术提供了多种客户端兼容模式，默认提供的是lettucs客户端技术，也可以根据需要切换成指定客户端技术，例如jedis客户端技术，切换成jedis客户端技术操作步骤如下：\r步骤①：导入jedis坐标\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;redis.clients\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;jedis\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; ​\tjedis坐标受springboot管理，无需提供版本号\n步骤②：配置客户端技术类型，设置为jedis\nspring: redis: host: localhost port: 6379 client-type: jedis 步骤③：根据需要设置对应的配置\nspring: redis: host: localhost port: 6379 client-type: jedis lettuce: pool: max-active: 16 jedis: pool: max-active: 16 lettcus与jedis区别\njedis连接Redis服务器是直连模式，当多线程模式下使用jedis会存在线程安全问题，解决方案可以通过配置连接池使每个连接专用，这样整体性能就大受影响 lettcus基于Netty框架进行与Redis服务器连接，底层设计中采用StatefulRedisConnection。 StatefulRedisConnection自身是线程安全的，可以保障并发访问安全问题，所以一个连接可以被多线程复用。当然lettcus也支持多连接实例一起工作 总结\nspringboot整合redis提供了StringRedisTemplate对象，以字符串的数据格式操作redis 如果需要切换redis客户端实现技术，可以通过配置的形式进行 SpringBoot整合MongoDB\r​\t使用Redis技术可以有效的提高数据访问速度，但是由于Redis的数据格式单一性，无法操作结构化数据，当操作对象型的数据时，Redis就显得捉襟见肘。在保障访问速度的情况下，如果想操作结构化数据，看来Redis无法满足要求了，此时需要使用全新的数据存储结束来解决此问题，本节讲解springboot如何整合MongoDB技术。\n​\tMongoDB是一个开源、高性能、无模式的文档型数据库，它是NoSQL数据库产品中的一种，是最像关系型数据库的非关系型数据库。\n​\t上述描述中几个词，其中对于我们最陌生的词是无模式的。什么叫无模式呢？简单说就是作为一款数据库，没有固定的数据存储结构，第一条数据可能有A、B、C一共3个字段，第二条数据可能有D、E、F也是3个字段，第三条数据可能是A、C、E3个字段，也就是说数据的结构不固定，这就是无模式。有人会说这有什么用啊？灵活，随时变更，不受约束。基于上述特点，MongoDB的应用面也会产生一些变化。以下列出了一些可以使用MongoDB作为数据存储的场景，但是并不是必须使用MongoDB的场景：\n淘宝用户数据 存储位置：数据库 特征：永久性存储，修改频度极低 游戏装备数据、游戏道具数据 存储位置：数据库、Mongodb 特征：永久性存储与临时存储相结合、修改频度较高 直播数据、打赏数据、粉丝数据 存储位置：数据库、Mongodb 特征：永久性存储与临时存储相结合，修改频度极高 物联网数据 存储位置：Mongodb 特征：临时存储，修改频度飞速 ​\t快速了解一下MongoDB，下面直接开始我们的学习，老规矩，先安装，再操作，最后说整合\n安装\r​\twindows版安装包下载地址：https://www.mongodb.com/try/download\n​\t下载的安装包也有两种形式，一种是一键安装的msi文件，还有一种是解压缩就能使用的zip文件，哪种形式都行，本课程采用解压缩zip文件进行安装。\n​\t解压缩完毕后会得到如下文件，其中bin目录包含了所有mongodb的可执行命令\n​\tmongodb在运行时需要指定一个数据存储的目录，所以创建一个数据存储目录，通常放置在安装目录中，此处创建data的目录用来存储数据，具体如下\n​\t如果在安装的过程中出现了如下警告信息，就是告诉你，你当前的操作系统缺少了一些系统文件，这个不用担心。\n​\t根据下列方案即可解决，在浏览器中搜索提示缺少的名称对应的文件，并下载，将下载的文件拷贝到windows安装目录的system32目录下，然后在命令行中执行regsvr32命令注册此文件。根据下载的文件名不同，执行命令前更改对应名称。\nregsvr32 vcruntime140_1.dll 启动服务器\nmongod --dbpath=..\\data\\db ​\t启动服务器时需要指定数据存储位置，通过参数\u0026ndash;dbpath进行设置，可以根据需要自行设置数据存储路径。默认服务端口27017。\n启动客户端\nmongo --host=127.0.0.1 --port=27017 基本操作\r​\tMongoDB虽然是一款数据库，但是它的操作并不是使用SQL语句进行的，因此操作方式各位小伙伴可能比较陌生，好在有一些类似于Navicat的数据库客户端软件，能够便捷的操作MongoDB，先安装一个客户端，再来操作MongoDB。\n​\t同类型的软件较多，本次安装的软件时Robo3t，Robot3t是一款绿色软件，无需安装，解压缩即可。解压缩完毕后进入安装目录双击robot3t.exe即可使用。\n​\t打开软件首先要连接MongoDB服务器，选择【File】菜单，选择【Connect\u0026hellip;】\n​\t进入连接管理界面后，选择左上角的【Create】链接，创建新的连接设置\n​\t如果输入设置值即可连接（默认不修改即可连接本机27017端口）\n​\t连接成功后在命令输入区域输入命令即可操作MongoDB。\n​\t创建数据库：在左侧菜单中使用右键创建，输入数据库名称即可\n​\t创建集合：在Collections上使用右键创建，输入集合名称即可，集合等同于数据库中的表的作用\n​\t新增文档：（文档是一种类似json格式的数据，初学者可以先把数据理解为就是json数据）\ndb.集合名称.insert/save/insertOne(文档) ​\t删除文档：\ndb.集合名称.remove(条件) ​\t修改文档：\ndb.集合名称.update(条件，{操作种类:{文档}}) ​\t查询文档：\n基础查询\r查询全部：\tdb.集合.find();\r查第一条：\tdb.集合.findOne()\r查询指定数量文档：\tdb.集合.find().limit(10)\t//查10条文档\r跳过指定数量文档：\tdb.集合.find().skip(20)\t//跳过20条文档\r统计：\tdb.集合.count()\r排序：\tdb.集合.sort({age:1})\t//按age升序排序\r投影：\tdb.集合名称.find(条件,{name:1,age:1})\t//仅保留name与age域\r条件查询\r基本格式：\tdb.集合.find({条件})\r模糊查询：\tdb.集合.find({域名:/正则表达式/})\t//等同SQL中的like，比like强大，可以执行正则所有规则\r条件比较运算：\tdb.集合.find({域名:{$gt:值}})\t//等同SQL中的数值比较操作，例如：name\u0026gt;18\r包含查询：\tdb.集合.find({域名:{$in:[值1，值2]}})\t//等同于SQL中的in\r条件连接查询：\tdb.集合.find({$and:[{条件1},{条件2}]})\t//等同于SQL中的and、or ​\t有关MongoDB的基础操作就普及到这里，需要全面掌握MongoDB技术，请参看相关教程学习。\n整合\r​\t使用springboot整合MongDB该如何进行呢？其实springboot为什么使用的开发者这么多，就是因为他的套路几乎完全一样。导入坐标，做配置，使用API接口操作。整合Redis如此，整合MongoDB同样如此。\n​\t第一，先导入对应技术的整合starter坐标\n​\t第二，配置必要信息\n​\t第三，使用提供的API操作即可\n​\t下面就开始springboot整合MongoDB，操作步骤如下：\n步骤①：导入springboot整合MongoDB的starter坐标\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-data-mongodb\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; ​\t上述坐标也可以在创建模块的时候通过勾选的形式进行选择，同样归属NoSQL分类中\n步骤②：进行基础配置\nspring: data: mongodb: uri: mongodb://localhost/itheima ​\t操作MongoDB需要的配置与操作redis一样，最基本的信息都是操作哪一台服务器，区别就是连接的服务器IP地址和端口不同，书写格式不同而已。\n步骤③：使用springboot整合MongoDB的专用客户端接口MongoTemplate来进行操作\n@SpringBootTest class Springboot17MongodbApplicationTests { @Autowired private MongoTemplate mongoTemplate; @Test void contextLoads() { Book book = new Book(); book.setId(2); book.setName(\u0026#34;springboot2\u0026#34;); book.setType(\u0026#34;springboot2\u0026#34;); book.setDescription(\u0026#34;springboot2\u0026#34;); mongoTemplate.save(book); } @Test void find(){ List\u0026lt;Book\u0026gt; all = mongoTemplate.findAll(Book.class); System.out.println(all); } } ​\t整合工作到这里就做完了，感觉既熟悉也陌生。熟悉的是这个套路，三板斧，就这三招，导坐标做配置用API操作，陌生的是这个技术，里面具体的操作API可能会不熟悉，有关springboot整合MongoDB我们就讲到这里。有兴趣可以继续学习MongoDB的操作，然后再来这里通过编程的形式操作MongoDB。\n总结\nspringboot整合MongoDB步骤 导入springboot整合MongoDB的starter坐标 进行基础配置 使用springboot整合MongoDB的专用客户端接口MongoTemplate操作 SpringBoot整合ES\r​\tNoSQL解决方案已经讲完了两种技术的整合了，Redis可以使用内存加载数据并实现数据快速访问，MongoDB可以在内存中存储类似对象的数据并实现数据的快速访问，在企业级开发中对于速度的追求是永无止境的。下面要讲的内容也是一款NoSQL解决方案，只不过他的作用不是为了直接加速数据的读写，而是加速数据的查询的，叫做ES技术。\n​\tES（Elasticsearch）是一个分布式全文搜索引擎，重点是全文搜索。\n​\t那什么是全文搜索呢？比如用户要买一本书，以Java为关键字进行搜索，不管是书名中还是书的介绍中，甚至是书的作者名字，只要包含java就作为查询结果返回给用户查看，上述过程就使用了全文搜索技术。搜索的条件不再是仅用于对某一个字段进行比对，而是在一条数据中使用搜索条件去比对更多的字段，只要能匹配上就列入查询结果，这就是全文搜索的目的。而ES技术就是一种可以实现上述效果的技术。\n​\t要实现全文搜索的效果，不可能使用数据库中like操作去进行比对，这种效率太低了。ES设计了一种全新的思想，来实现全文搜索。具体操作过程如下：\n将被查询的字段的数据全部文本信息进行查分，分成若干个词\n例如“中华人民共和国”就会被拆分成三个词，分别是“中华”、“人民”、“共和国”，此过程有专业术语叫做分词。分词的策略不同，分出的效果不一样，不同的分词策略称为分词器。 将分词得到的结果存储起来，对应每条数据的id\n例如id为1的数据中名称这一项的值是“中华人民共和国”，那么分词结束后，就会出现“中华”对应id为1，“人民”对应id为1，“共和国”对应id为1\n例如id为2的数据中名称这一项的值是“人民代表大会“，那么分词结束后，就会出现“人民”对应id为2，“代表”对应id为2，“大会”对应id为2\n此时就会出现如下对应结果，按照上述形式可以对所有文档进行分词。需要注意分词的过程不是仅对一个字段进行，而是对每一个参与查询的字段都执行，最终结果汇总到一个表格中\n分词结果关键字 对应id 中华 1 人民 1,2 共和国 1 代表 2 大会 2 当进行查询时，如果输入“人民”作为查询条件，可以通过上述表格数据进行比对，得到id值1,2，然后根据id值就可以得到查询的结果数据了。\n​\t上述过程中分词结果关键字内容每一个都不相同，作用有点类似于数据库中的索引，是用来加速数据查询的。但是数据库中的索引是对某一个字段进行添加索引，而这里的分词结果关键字不是一个完整的字段值，只是一个字段中的其中的一部分内容。并且索引使用时是根据索引内容查找整条数据，全文搜索中的分词结果关键字查询后得到的并不是整条的数据，而是数据的id，要想获得具体数据还要再次查询，因此这里为这种分词结果关键字起了一个全新的名称，叫做倒排索引。\n​\t通过上述内容的学习，发现使用ES其实准备工作还是挺多的，必须先建立文档的倒排索引，然后才能继续使用。快速了解一下ES的工作原理，下面直接开始我们的学习，老规矩，先安装，再操作，最后说整合。\n安装\r​\twindows版安装包下载地址：https://www.elastic.co/cn/downloads/elasticsearch\n​\t下载的安装包是解压缩就能使用的zip文件，解压缩完毕后会得到如下文件\nbin目录：包含所有的可执行命令 config目录：包含ES服务器使用的配置文件 jdk目录：此目录中包含了一个完整的jdk工具包，版本17，当ES升级时，使用最新版本的jdk确保不会出现版本支持性不足的问题 lib目录：包含ES运行的依赖jar文件 logs目录：包含ES运行后产生的所有日志文件 modules目录：包含ES软件中所有的功能模块，也是一个一个的jar包。和jar目录不同，jar目录是ES运行期间依赖的jar包，modules是ES软件自己的功能jar包 plugins目录：包含ES软件安装的插件，默认为空 启动服务器\nelasticsearch.bat ​\t双击elasticsearch.bat文件即可启动ES服务器，默认服务端口9200。通过浏览器访问http://localhost:9200看到如下信息视为ES服务器正常启动\n{\r\u0026#34;name\u0026#34; : \u0026#34;CZBK-**********\u0026#34;,\r\u0026#34;cluster_name\u0026#34; : \u0026#34;elasticsearch\u0026#34;,\r\u0026#34;cluster_uuid\u0026#34; : \u0026#34;j137DSswTPG8U4Yb-0T1Mg\u0026#34;,\r\u0026#34;version\u0026#34; : {\r\u0026#34;number\u0026#34; : \u0026#34;7.16.2\u0026#34;,\r\u0026#34;build_flavor\u0026#34; : \u0026#34;default\u0026#34;,\r\u0026#34;build_type\u0026#34; : \u0026#34;zip\u0026#34;,\r\u0026#34;build_hash\u0026#34; : \u0026#34;2b937c44140b6559905130a8650c64dbd0879cfb\u0026#34;,\r\u0026#34;build_date\u0026#34; : \u0026#34;2021-12-18T19:42:46.604893745Z\u0026#34;,\r\u0026#34;build_snapshot\u0026#34; : false,\r\u0026#34;lucene_version\u0026#34; : \u0026#34;8.10.1\u0026#34;,\r\u0026#34;minimum_wire_compatibility_version\u0026#34; : \u0026#34;6.8.0\u0026#34;,\r\u0026#34;minimum_index_compatibility_version\u0026#34; : \u0026#34;6.0.0-beta1\u0026#34;\r},\r\u0026#34;tagline\u0026#34; : \u0026#34;You Know, for Search\u0026#34;\r} 基本操作\r​\tES中保存有我们要查询的数据，只不过格式和数据库存储数据格式不同而已。在ES中我们要先创建倒排索引，这个索引的功能又点类似于数据库的表，然后将数据添加到倒排索引中，添加的数据称为文档。所以要进行ES的操作要先创建索引，再添加文档，这样才能进行后续的查询操作。\n​\t要操作ES可以通过Rest风格的请求来进行，也就是说发送一个请求就可以执行一个操作。比如新建索引，删除索引这些操作都可以使用发送请求的形式来进行。\n创建索引，books是索引名称，下同\nPUT请求\thttp://localhost:9200/books 发送请求后，看到如下信息即索引创建成功\n{ \u0026#34;acknowledged\u0026#34;: true, \u0026#34;shards_acknowledged\u0026#34;: true, \u0026#34;index\u0026#34;: \u0026#34;books\u0026#34; } 重复创建已经存在的索引会出现错误信息，reason属性中描述错误原因\n{ \u0026#34;error\u0026#34;: { \u0026#34;root_cause\u0026#34;: [ { \u0026#34;type\u0026#34;: \u0026#34;resource_already_exists_exception\u0026#34;, \u0026#34;reason\u0026#34;: \u0026#34;index [books/VgC_XMVAQmedaiBNSgO2-w] already exists\u0026#34;, \u0026#34;index_uuid\u0026#34;: \u0026#34;VgC_XMVAQmedaiBNSgO2-w\u0026#34;, \u0026#34;index\u0026#34;: \u0026#34;books\u0026#34; } ], \u0026#34;type\u0026#34;: \u0026#34;resource_already_exists_exception\u0026#34;, \u0026#34;reason\u0026#34;: \u0026#34;index [books/VgC_XMVAQmedaiBNSgO2-w] already exists\u0026#34;,\t# books索引已经存在 \u0026#34;index_uuid\u0026#34;: \u0026#34;VgC_XMVAQmedaiBNSgO2-w\u0026#34;, \u0026#34;index\u0026#34;: \u0026#34;book\u0026#34; }, \u0026#34;status\u0026#34;: 400 } 查询索引\nGET请求\thttp://localhost:9200/books 查询索引得到索引相关信息，如下\n{ \u0026#34;book\u0026#34;: { \u0026#34;aliases\u0026#34;: {}, \u0026#34;mappings\u0026#34;: {}, \u0026#34;settings\u0026#34;: { \u0026#34;index\u0026#34;: { \u0026#34;routing\u0026#34;: { \u0026#34;allocation\u0026#34;: { \u0026#34;include\u0026#34;: { \u0026#34;_tier_preference\u0026#34;: \u0026#34;data_content\u0026#34; } } }, \u0026#34;number_of_shards\u0026#34;: \u0026#34;1\u0026#34;, \u0026#34;provided_name\u0026#34;: \u0026#34;books\u0026#34;, \u0026#34;creation_date\u0026#34;: \u0026#34;1645768584849\u0026#34;, \u0026#34;number_of_replicas\u0026#34;: \u0026#34;1\u0026#34;, \u0026#34;uuid\u0026#34;: \u0026#34;VgC_XMVAQmedaiBNSgO2-w\u0026#34;, \u0026#34;version\u0026#34;: { \u0026#34;created\u0026#34;: \u0026#34;7160299\u0026#34; } } } } } 如果查询了不存在的索引，会返回错误信息，例如查询名称为book的索引后信息如下\n{ \u0026#34;error\u0026#34;: { \u0026#34;root_cause\u0026#34;: [ { \u0026#34;type\u0026#34;: \u0026#34;index_not_found_exception\u0026#34;, \u0026#34;reason\u0026#34;: \u0026#34;no such index [book]\u0026#34;, \u0026#34;resource.type\u0026#34;: \u0026#34;index_or_alias\u0026#34;, \u0026#34;resource.id\u0026#34;: \u0026#34;book\u0026#34;, \u0026#34;index_uuid\u0026#34;: \u0026#34;_na_\u0026#34;, \u0026#34;index\u0026#34;: \u0026#34;book\u0026#34; } ], \u0026#34;type\u0026#34;: \u0026#34;index_not_found_exception\u0026#34;, \u0026#34;reason\u0026#34;: \u0026#34;no such index [book]\u0026#34;,\t# 没有book索引 \u0026#34;resource.type\u0026#34;: \u0026#34;index_or_alias\u0026#34;, \u0026#34;resource.id\u0026#34;: \u0026#34;book\u0026#34;, \u0026#34;index_uuid\u0026#34;: \u0026#34;_na_\u0026#34;, \u0026#34;index\u0026#34;: \u0026#34;book\u0026#34; }, \u0026#34;status\u0026#34;: 404 } 删除索引\nDELETE请求\thttp://localhost:9200/books 删除所有后，给出删除结果\n{ \u0026#34;acknowledged\u0026#34;: true } 如果重复删除，会给出错误信息，同样在reason属性中描述具体的错误原因\n{ \u0026#34;error\u0026#34;: { \u0026#34;root_cause\u0026#34;: [ { \u0026#34;type\u0026#34;: \u0026#34;index_not_found_exception\u0026#34;, \u0026#34;reason\u0026#34;: \u0026#34;no such index [books]\u0026#34;, \u0026#34;resource.type\u0026#34;: \u0026#34;index_or_alias\u0026#34;, \u0026#34;resource.id\u0026#34;: \u0026#34;book\u0026#34;, \u0026#34;index_uuid\u0026#34;: \u0026#34;_na_\u0026#34;, \u0026#34;index\u0026#34;: \u0026#34;book\u0026#34; } ], \u0026#34;type\u0026#34;: \u0026#34;index_not_found_exception\u0026#34;, \u0026#34;reason\u0026#34;: \u0026#34;no such index [books]\u0026#34;,\t# 没有books索引 \u0026#34;resource.type\u0026#34;: \u0026#34;index_or_alias\u0026#34;, \u0026#34;resource.id\u0026#34;: \u0026#34;book\u0026#34;, \u0026#34;index_uuid\u0026#34;: \u0026#34;_na_\u0026#34;, \u0026#34;index\u0026#34;: \u0026#34;book\u0026#34; }, \u0026#34;status\u0026#34;: 404 } 创建索引并指定分词器\n​\t前面创建的索引是未指定分词器的，可以在创建索引时添加请求参数，设置分词器。目前国内较为流行的分词器是IK分词器，使用前先在下对应的分词器，然后使用。IK分词器下载地址：https://github.com/medcl/elasticsearch-analysis-ik/releases\n​\t分词器下载后解压到ES安装目录的plugins目录中即可，安装分词器后需要重新启动ES服务器。使用IK分词器创建索引格式：\nPUT请求\thttp://localhost:9200/books 请求参数如下（注意是json格式的参数） { \u0026#34;mappings\u0026#34;:{\t#定义mappings属性，替换创建索引时对应的mappings属性\t\u0026#34;properties\u0026#34;:{\t#定义索引中包含的属性设置 \u0026#34;id\u0026#34;:{\t#设置索引中包含id属性 \u0026#34;type\u0026#34;:\u0026#34;keyword\u0026#34;\t#当前属性可以被直接搜索 }, \u0026#34;name\u0026#34;:{\t#设置索引中包含name属性 \u0026#34;type\u0026#34;:\u0026#34;text\u0026#34;, #当前属性是文本信息，参与分词 \u0026#34;analyzer\u0026#34;:\u0026#34;ik_max_word\u0026#34;, #使用IK分词器进行分词 \u0026#34;copy_to\u0026#34;:\u0026#34;all\u0026#34;\t#分词结果拷贝到all属性中 }, \u0026#34;type\u0026#34;:{ \u0026#34;type\u0026#34;:\u0026#34;keyword\u0026#34; }, \u0026#34;description\u0026#34;:{ \u0026#34;type\u0026#34;:\u0026#34;text\u0026#34;,\t\u0026#34;analyzer\u0026#34;:\u0026#34;ik_max_word\u0026#34;, \u0026#34;copy_to\u0026#34;:\u0026#34;all\u0026#34; }, \u0026#34;all\u0026#34;:{\t#定义属性，用来描述多个字段的分词结果集合，当前属性可以参与查询 \u0026#34;type\u0026#34;:\u0026#34;text\u0026#34;,\t\u0026#34;analyzer\u0026#34;:\u0026#34;ik_max_word\u0026#34; } } } } ​\t创建完毕后返回结果和不使用分词器创建索引的结果是一样的，此时可以通过查看索引信息观察到添加的请求参数mappings已经进入到了索引属性中\n{ \u0026#34;books\u0026#34;: { \u0026#34;aliases\u0026#34;: {}, \u0026#34;mappings\u0026#34;: {\t#mappings属性已经被替换 \u0026#34;properties\u0026#34;: { \u0026#34;all\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;text\u0026#34;, \u0026#34;analyzer\u0026#34;: \u0026#34;ik_max_word\u0026#34; }, \u0026#34;description\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;text\u0026#34;, \u0026#34;copy_to\u0026#34;: [ \u0026#34;all\u0026#34; ], \u0026#34;analyzer\u0026#34;: \u0026#34;ik_max_word\u0026#34; }, \u0026#34;id\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;keyword\u0026#34; }, \u0026#34;name\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;text\u0026#34;, \u0026#34;copy_to\u0026#34;: [ \u0026#34;all\u0026#34; ], \u0026#34;analyzer\u0026#34;: \u0026#34;ik_max_word\u0026#34; }, \u0026#34;type\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;keyword\u0026#34; } } }, \u0026#34;settings\u0026#34;: { \u0026#34;index\u0026#34;: { \u0026#34;routing\u0026#34;: { \u0026#34;allocation\u0026#34;: { \u0026#34;include\u0026#34;: { \u0026#34;_tier_preference\u0026#34;: \u0026#34;data_content\u0026#34; } } }, \u0026#34;number_of_shards\u0026#34;: \u0026#34;1\u0026#34;, \u0026#34;provided_name\u0026#34;: \u0026#34;books\u0026#34;, \u0026#34;creation_date\u0026#34;: \u0026#34;1645769809521\u0026#34;, \u0026#34;number_of_replicas\u0026#34;: \u0026#34;1\u0026#34;, \u0026#34;uuid\u0026#34;: \u0026#34;DohYKvr_SZO4KRGmbZYmTQ\u0026#34;, \u0026#34;version\u0026#34;: { \u0026#34;created\u0026#34;: \u0026#34;7160299\u0026#34; } } } } } 目前我们已经有了索引了，但是索引中还没有数据，所以要先添加数据，ES中称数据为文档，下面进行文档操作。\n添加文档，有三种方式\nPOST请求\thttp://localhost:9200/books/_doc\t#使用系统生成id POST请求\thttp://localhost:9200/books/_create/1\t#使用指定id POST请求\thttp://localhost:9200/books/_doc/1\t#使用指定id，不存在创建，存在更新（版本递增） 文档通过请求参数传递，数据格式json { \u0026#34;name\u0026#34;:\u0026#34;springboot\u0026#34;, \u0026#34;type\u0026#34;:\u0026#34;springboot\u0026#34;, \u0026#34;description\u0026#34;:\u0026#34;springboot\u0026#34; } 查询文档\nGET请求\thttp://localhost:9200/books/_doc/1\t#查询单个文档 GET请求\thttp://localhost:9200/books/_search\t#查询全部文档 条件查询\nGET请求\thttp://localhost:9200/books/_search?q=name:springboot\t# q=查询属性名:查询属性值 删除文档\nDELETE请求\thttp://localhost:9200/books/_doc/1 修改文档（全量更新）\nPUT请求\thttp://localhost:9200/books/_doc/1 文档通过请求参数传递，数据格式json { \u0026#34;name\u0026#34;:\u0026#34;springboot\u0026#34;, \u0026#34;type\u0026#34;:\u0026#34;springboot\u0026#34;, \u0026#34;description\u0026#34;:\u0026#34;springboot\u0026#34; } 修改文档（部分更新）\nPOST请求\thttp://localhost:9200/books/_update/1 文档通过请求参数传递，数据格式json {\t\u0026#34;doc\u0026#34;:{\t#部分更新并不是对原始文档进行更新，而是对原始文档对象中的doc属性中的指定属性更新 \u0026#34;name\u0026#34;:\u0026#34;springboot\u0026#34;\t#仅更新提供的属性值，未提供的属性值不参与更新操作 } } 整合\r​\t使用springboot整合ES该如何进行呢？老规矩，导入坐标，做配置，使用API接口操作。整合Redis如此，整合MongoDB如此，整合ES依然如此。太没有新意了，其实不是没有新意，这就是springboot的强大之处，所有东西都做成相同规则，对开发者来说非常友好。\n​\t下面就开始springboot整合ES，操作步骤如下：\n步骤①：导入springboot整合ES的starter坐标\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-data-elasticsearch\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; 步骤②：进行基础配置\nspring: elasticsearch: rest: uris: http://localhost:9200 ​\t配置ES服务器地址，端口9200\n步骤③：使用springboot整合ES的专用客户端接口ElasticsearchRestTemplate来进行操作\n@SpringBootTest class Springboot18EsApplicationTests { @Autowired private ElasticsearchRestTemplate template; } ​\t上述操作形式是ES早期的操作方式，使用的客户端被称为Low Level Client，这种客户端操作方式性能方面略显不足，于是ES开发了全新的客户端操作方式，称为High Level Client。高级别客户端与ES版本同步更新，但是springboot最初整合ES的时候使用的是低级别客户端，所以企业开发需要更换成高级别的客户端模式。\n​\t下面使用高级别客户端方式进行springboot整合ES，操作步骤如下：\n步骤①：导入springboot整合ES高级别客户端的坐标，此种形式目前没有对应的starter\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.elasticsearch.client\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;elasticsearch-rest-high-level-client\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; 步骤②：使用编程的形式设置连接的ES服务器，并获取客户端对象\n@SpringBootTest class Springboot18EsApplicationTests { private RestHighLevelClient client; @Test void testCreateClient() throws IOException { HttpHost host = HttpHost.create(\u0026#34;http://localhost:9200\u0026#34;); RestClientBuilder builder = RestClient.builder(host); client = new RestHighLevelClient(builder); client.close(); } } ​\t配置ES服务器地址与端口9200，记得客户端使用完毕需要手工关闭。由于当前客户端是手工维护的，因此不能通过自动装配的形式加载对象。\n步骤③：使用客户端对象操作ES，例如创建索引\n@SpringBootTest class Springboot18EsApplicationTests { private RestHighLevelClient client; @Test void testCreateIndex() throws IOException { HttpHost host = HttpHost.create(\u0026#34;http://localhost:9200\u0026#34;); RestClientBuilder builder = RestClient.builder(host); client = new RestHighLevelClient(builder); CreateIndexRequest request = new CreateIndexRequest(\u0026#34;books\u0026#34;); client.indices().create(request, RequestOptions.DEFAULT); client.close(); } } ​\t高级别客户端操作是通过发送请求的方式完成所有操作的，ES针对各种不同的操作，设定了各式各样的请求对象，上例中创建索引的对象是CreateIndexRequest，其他操作也会有自己专用的Request对象。\n​\t当前操作我们发现，无论进行ES何种操作，第一步永远是获取RestHighLevelClient对象，最后一步永远是关闭该对象的连接。在测试中可以使用测试类的特性去帮助开发者一次性的完成上述操作，但是在业务书写时，还需要自行管理。将上述代码格式转换成使用测试类的初始化方法和销毁方法进行客户端对象的维护。\n@SpringBootTest class Springboot18EsApplicationTests { @BeforeEach\t//在测试类中每个操作运行前运行的方法 void setUp() { HttpHost host = HttpHost.create(\u0026#34;http://localhost:9200\u0026#34;); RestClientBuilder builder = RestClient.builder(host); client = new RestHighLevelClient(builder); } @AfterEach\t//在测试类中每个操作运行后运行的方法 void tearDown() throws IOException { client.close(); } private RestHighLevelClient client; @Test void testCreateIndex() throws IOException { CreateIndexRequest request = new CreateIndexRequest(\u0026#34;books\u0026#34;); client.indices().create(request, RequestOptions.DEFAULT); } } ​\t现在的书写简化了很多，也更合理。下面使用上述模式将所有的ES操作执行一遍，测试结果\n创建索引（IK分词器）：\n@Test void testCreateIndexByIK() throws IOException { CreateIndexRequest request = new CreateIndexRequest(\u0026#34;books\u0026#34;); String json = \u0026#34;{\\n\u0026#34; + \u0026#34; \\\u0026#34;mappings\\\u0026#34;:{\\n\u0026#34; + \u0026#34; \\\u0026#34;properties\\\u0026#34;:{\\n\u0026#34; + \u0026#34; \\\u0026#34;id\\\u0026#34;:{\\n\u0026#34; + \u0026#34; \\\u0026#34;type\\\u0026#34;:\\\u0026#34;keyword\\\u0026#34;\\n\u0026#34; + \u0026#34; },\\n\u0026#34; + \u0026#34; \\\u0026#34;name\\\u0026#34;:{\\n\u0026#34; + \u0026#34; \\\u0026#34;type\\\u0026#34;:\\\u0026#34;text\\\u0026#34;,\\n\u0026#34; + \u0026#34; \\\u0026#34;analyzer\\\u0026#34;:\\\u0026#34;ik_max_word\\\u0026#34;,\\n\u0026#34; + \u0026#34; \\\u0026#34;copy_to\\\u0026#34;:\\\u0026#34;all\\\u0026#34;\\n\u0026#34; + \u0026#34; },\\n\u0026#34; + \u0026#34; \\\u0026#34;type\\\u0026#34;:{\\n\u0026#34; + \u0026#34; \\\u0026#34;type\\\u0026#34;:\\\u0026#34;keyword\\\u0026#34;\\n\u0026#34; + \u0026#34; },\\n\u0026#34; + \u0026#34; \\\u0026#34;description\\\u0026#34;:{\\n\u0026#34; + \u0026#34; \\\u0026#34;type\\\u0026#34;:\\\u0026#34;text\\\u0026#34;,\\n\u0026#34; + \u0026#34; \\\u0026#34;analyzer\\\u0026#34;:\\\u0026#34;ik_max_word\\\u0026#34;,\\n\u0026#34; + \u0026#34; \\\u0026#34;copy_to\\\u0026#34;:\\\u0026#34;all\\\u0026#34;\\n\u0026#34; + \u0026#34; },\\n\u0026#34; + \u0026#34; \\\u0026#34;all\\\u0026#34;:{\\n\u0026#34; + \u0026#34; \\\u0026#34;type\\\u0026#34;:\\\u0026#34;text\\\u0026#34;,\\n\u0026#34; + \u0026#34; \\\u0026#34;analyzer\\\u0026#34;:\\\u0026#34;ik_max_word\\\u0026#34;\\n\u0026#34; + \u0026#34; }\\n\u0026#34; + \u0026#34; }\\n\u0026#34; + \u0026#34; }\\n\u0026#34; + \u0026#34;}\u0026#34;; //设置请求中的参数 request.source(json, XContentType.JSON); client.indices().create(request, RequestOptions.DEFAULT); } ​\tIK分词器是通过请求参数的形式进行设置的，设置请求参数使用request对象中的source方法进行设置，至于参数是什么，取决于你的操作种类。当请求中需要参数时，均可使用当前形式进行参数设置。\n添加文档：\n@Test //添加文档 void testCreateDoc() throws IOException { Book book = bookDao.selectById(1); IndexRequest request = new IndexRequest(\u0026#34;books\u0026#34;).id(book.getId().toString()); String json = JSON.toJSONString(book); request.source(json,XContentType.JSON); client.index(request,RequestOptions.DEFAULT); } ​\t添加文档使用的请求对象是IndexRequest，与创建索引使用的请求对象不同。\n批量添加文档：\n@Test //批量添加文档 void testCreateDocAll() throws IOException { List\u0026lt;Book\u0026gt; bookList = bookDao.selectList(null); BulkRequest bulk = new BulkRequest(); for (Book book : bookList) { IndexRequest request = new IndexRequest(\u0026#34;books\u0026#34;).id(book.getId().toString()); String json = JSON.toJSONString(book); request.source(json,XContentType.JSON); bulk.add(request); } client.bulk(bulk,RequestOptions.DEFAULT); } ​\t批量做时，先创建一个BulkRequest的对象，可以将该对象理解为是一个保存request对象的容器，将所有的请求都初始化好后，添加到BulkRequest对象中，再使用BulkRequest对象的bulk方法，一次性执行完毕。\n按id查询文档：\n@Test //按id查询 void testGet() throws IOException { GetRequest request = new GetRequest(\u0026#34;books\u0026#34;,\u0026#34;1\u0026#34;); GetResponse response = client.get(request, RequestOptions.DEFAULT); String json = response.getSourceAsString(); System.out.println(json); } ​\t根据id查询文档使用的请求对象是GetRequest。\n按条件查询文档：\n@Test //按条件查询 void testSearch() throws IOException { SearchRequest request = new SearchRequest(\u0026#34;books\u0026#34;); SearchSourceBuilder builder = new SearchSourceBuilder(); builder.query(QueryBuilders.termQuery(\u0026#34;all\u0026#34;,\u0026#34;spring\u0026#34;)); request.source(builder); SearchResponse response = client.search(request, RequestOptions.DEFAULT); SearchHits hits = response.getHits(); for (SearchHit hit : hits) { String source = hit.getSourceAsString(); //System.out.println(source); Book book = JSON.parseObject(source, Book.class); System.out.println(book); } } ​\t按条件查询文档使用的请求对象是SearchRequest，查询时调用SearchRequest对象的termQuery方法，需要给出查询属性名，此处支持使用合并字段，也就是前面定义索引属性时添加的all属性。\n​\tspringboot整合ES的操作到这里就说完了，与前期进行springboot整合redis和mongodb的差别还是蛮大的，主要原始就是我们没有使用springboot整合ES的客户端对象。至于操作，由于ES操作种类过多，所以显得操作略微有点复杂。有关springboot整合ES就先学习到这里吧。\n总结\nspringboot整合ES步骤 导入springboot整合ES的High Level Client坐标 手工管理客户端对象，包括初始化和关闭操作 使用High Level Client根据操作的种类不同，选择不同的Request对象完成对应操作 KF-5.整合第三方技术\r​\t通过第四章的学习，我们领略到了springboot在整合第三方技术时强大的一致性，在第五章中我们要使用springboot继续整合各种各样的第三方技术，通过本章的学习，可以将之前学习的springboot整合第三方技术的思想贯彻到底，还是那三板斧。导坐标、做配置、调API。\n​\tspringboot能够整合的技术实在是太多了，可以说是万物皆可整。本章将从企业级开发中常用的一些技术作为出发点，对各种各样的技术进行整合。\nKF-5-1.缓存\r​\t企业级应用主要作用是信息处理，当需要读取数据时，由于受限于数据库的访问效率，导致整体系统性能偏低。\n​\t应用程序直接与数据库打交道，访问效率低\n​\t为了改善上述现象，开发者通常会在应用程序与数据库之间建立一种临时的数据存储机制，该区域中的数据在内存中保存，读写速度较快，可以有效解决数据库访问效率低下的问题。这一块临时存储数据的区域就是缓存。\n使用缓存后，应用程序与缓存打交道，缓存与数据库打交道，数据访问效率提高\r​\t缓存是什么？缓存是一种介于数据永久存储介质与应用程序之间的数据临时存储介质，使用缓存可以有效的减少低速数据读取过程的次数（例如磁盘IO），提高系统性能。此外缓存不仅可以用于提高永久性存储介质的数据读取效率，还可以提供临时的数据存储空间。而springboot提供了对市面上几乎所有的缓存技术进行整合的方案，下面就一起开启springboot整合缓存之旅。\nSpringBoot内置缓存解决方案\r​\tspringboot技术提供有内置的缓存解决方案，可以帮助开发者快速开启缓存技术，并使用缓存技术进行数据的快速操作，例如读取缓存数据和写入数据到缓存。\n步骤①：导入springboot提供的缓存技术对应的starter\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-cache\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; 步骤②：启用缓存，在引导类上方标注注解@EnableCaching配置springboot程序中可以使用缓存\n@SpringBootApplication //开启缓存功能 @EnableCaching public class Springboot19CacheApplication { public static void main(String[] args) { SpringApplication.run(Springboot19CacheApplication.class, args); } } 步骤③：设置操作的数据是否使用缓存\n@Service public class BookServiceImpl implements BookService { @Autowired private BookDao bookDao; @Cacheable(value=\u0026#34;cacheSpace\u0026#34;,key=\u0026#34;#id\u0026#34;) public Book getById(Integer id) { return bookDao.selectById(id); } } ​\t在业务方法上面使用注解@Cacheable声明当前方法的返回值放入缓存中，其中要指定缓存的存储位置，以及缓存中保存当前方法返回值对应的名称。上例中value属性描述缓存的存储位置，可以理解为是一个存储空间名，key属性描述了缓存中保存数据的名称，使用#id读取形参中的id值作为缓存名称。\n​\t使用@Cacheable注解后，执行当前操作，如果发现对应名称在缓存中没有数据，就正常读取数据，然后放入缓存；如果对应名称在缓存中有数据，就终止当前业务方法执行，直接返回缓存中的数据。\n手机验证码案例\r​\t为了便于下面演示各种各样的缓存技术，我们创建一个手机验证码的案例环境，模拟使用缓存保存手机验证码的过程。\n​\t手机验证码案例需求如下：\n输入手机号获取验证码，组织文档以短信形式发送给用户（页面模拟） 输入手机号和验证码验证结果 ​\t为了描述上述操作，我们制作两个表现层接口，一个用来模拟发送短信的过程，其实就是根据用户提供的手机号生成一个验证码，然后放入缓存，另一个用来模拟验证码校验的过程，其实就是使用传入的手机号和验证码进行匹配，并返回最终匹配结果。下面直接制作本案例的模拟代码，先以上例中springboot提供的内置缓存技术来完成当前案例的制作。\n步骤①：导入springboot提供的缓存技术对应的starter\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-cache\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; 步骤②：启用缓存，在引导类上方标注注解@EnableCaching配置springboot程序中可以使用缓存\n@SpringBootApplication //开启缓存功能 @EnableCaching public class Springboot19CacheApplication { public static void main(String[] args) { SpringApplication.run(Springboot19CacheApplication.class, args); } } 步骤③：定义验证码对应的实体类，封装手机号与验证码两个属性\n@Data public class SMSCode { private String tele; private String code; } 步骤④：定义验证码功能的业务层接口与实现类\npublic interface SMSCodeService { public String sendCodeToSMS(String tele); public boolean checkCode(SMSCode smsCode); } @Service public class SMSCodeServiceImpl implements SMSCodeService { @Autowired private CodeUtils codeUtils; @CachePut(value = \u0026#34;smsCode\u0026#34;, key = \u0026#34;#tele\u0026#34;) public String sendCodeToSMS(String tele) { String code = codeUtils.generator(tele); return code; } public boolean checkCode(SMSCode smsCode) { //取出内存中的验证码与传递过来的验证码比对，如果相同，返回true String code = smsCode.getCode(); String cacheCode = codeUtils.get(smsCode.getTele()); return code.equals(cacheCode); } } ​\t获取验证码后，当验证码失效时必须重新获取验证码，因此在获取验证码的功能上不能使用@Cacheable注解，@Cacheable注解是缓存中没有值则放入值，缓存中有值则取值。此处的功能仅仅是生成验证码并放入缓存，并不具有从缓存中取值的功能，因此不能使用@Cacheable注解，应该使用仅具有向缓存中保存数据的功能，使用@CachePut注解即可。\n​\t对于校验验证码的功能建议放入工具类中进行。\n步骤⑤：定义验证码的生成策略与根据手机号读取验证码的功能\n@Component public class CodeUtils { private String [] patch = {\u0026#34;000000\u0026#34;,\u0026#34;00000\u0026#34;,\u0026#34;0000\u0026#34;,\u0026#34;000\u0026#34;,\u0026#34;00\u0026#34;,\u0026#34;0\u0026#34;,\u0026#34;\u0026#34;}; public String generator(String tele){ int hash = tele.hashCode(); int encryption = 20206666; long result = hash ^ encryption; long nowTime = System.currentTimeMillis(); result = result ^ nowTime; long code = result % 1000000; code = code \u0026lt; 0 ? -code : code; String codeStr = code + \u0026#34;\u0026#34;; int len = codeStr.length(); return patch[len] + codeStr; } @Cacheable(value = \u0026#34;smsCode\u0026#34;,key=\u0026#34;#tele\u0026#34;) public String get(String tele){ return null; } } 步骤⑥：定义验证码功能的web层接口，一个方法用于提供手机号获取验证码，一个方法用于提供手机号和验证码进行校验\n@RestController @RequestMapping(\u0026#34;/sms\u0026#34;) public class SMSCodeController { @Autowired private SMSCodeService smsCodeService; @GetMapping public String getCode(String tele){ String code = smsCodeService.sendCodeToSMS(tele); return code; } @PostMapping public boolean checkCode(SMSCode smsCode){ return smsCodeService.checkCode(smsCode); } } SpringBoot整合Ehcache缓存\r​\t手机验证码的案例已经完成了，下面就开始springboot整合各种各样的缓存技术，第一个整合Ehcache技术。Ehcache是一种缓存技术，使用springboot整合Ehcache其实就是变更一下缓存技术的实现方式，话不多说，直接开整\n步骤①：导入Ehcache的坐标\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;net.sf.ehcache\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;ehcache\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; ​\t此处为什么不是导入Ehcache的starter，而是导入技术坐标呢？其实springboot整合缓存技术做的是通用格式，不管你整合哪种缓存技术，只是实现变化了，操作方式一样。这也体现出springboot技术的优点，统一同类技术的整合方式。\n步骤②：配置缓存技术实现使用Ehcache\nspring: cache: type: ehcache ehcache: config: ehcache.xml ​\t配置缓存的类型type为ehcache，此处需要说明一下，当前springboot可以整合的缓存技术中包含有ehcach，所以可以这样书写。其实这个type不可以随便写的，不是随便写一个名称就可以整合的。\n​\t由于ehcache的配置有独立的配置文件格式，因此还需要指定ehcache的配置文件，以便于读取相应配置\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;ehcache xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:noNamespaceSchemaLocation=\u0026#34;http://ehcache.org/ehcache.xsd\u0026#34; updateCheck=\u0026#34;false\u0026#34;\u0026gt; \u0026lt;diskStore path=\u0026#34;D:\\ehcache\u0026#34; /\u0026gt; \u0026lt;!--默认缓存策略 --\u0026gt; \u0026lt;!-- external：是否永久存在，设置为true则不会被清除，此时与timeout冲突，通常设置为false--\u0026gt; \u0026lt;!-- diskPersistent：是否启用磁盘持久化--\u0026gt; \u0026lt;!-- maxElementsInMemory：最大缓存数量--\u0026gt; \u0026lt;!-- overflowToDisk：超过最大缓存数量是否持久化到磁盘--\u0026gt; \u0026lt;!-- timeToIdleSeconds：最大不活动间隔，设置过长缓存容易溢出，设置过短无效果，可用于记录时效性数据，例如验证码--\u0026gt; \u0026lt;!-- timeToLiveSeconds：最大存活时间--\u0026gt; \u0026lt;!-- memoryStoreEvictionPolicy：缓存清除策略--\u0026gt; \u0026lt;defaultCache eternal=\u0026#34;false\u0026#34; diskPersistent=\u0026#34;false\u0026#34; maxElementsInMemory=\u0026#34;1000\u0026#34; overflowToDisk=\u0026#34;false\u0026#34; timeToIdleSeconds=\u0026#34;60\u0026#34; timeToLiveSeconds=\u0026#34;60\u0026#34; memoryStoreEvictionPolicy=\u0026#34;LRU\u0026#34; /\u0026gt; \u0026lt;cache name=\u0026#34;smsCode\u0026#34; eternal=\u0026#34;false\u0026#34; diskPersistent=\u0026#34;false\u0026#34; maxElementsInMemory=\u0026#34;1000\u0026#34; overflowToDisk=\u0026#34;false\u0026#34; timeToIdleSeconds=\u0026#34;10\u0026#34; timeToLiveSeconds=\u0026#34;10\u0026#34; memoryStoreEvictionPolicy=\u0026#34;LRU\u0026#34; /\u0026gt; \u0026lt;/ehcache\u0026gt; ​\t注意前面的案例中，设置了数据保存的位置是smsCode\n@CachePut(value = \u0026#34;smsCode\u0026#34;, key = \u0026#34;#tele\u0026#34;) public String sendCodeToSMS(String tele) { String code = codeUtils.generator(tele); return code; }\t​\t这个设定需要保障ehcache中有一个缓存空间名称叫做smsCode的配置，前后要统一。在企业开发过程中，通过设置不同名称的cache来设定不同的缓存策略，应用于不同的缓存数据。\n​\t到这里springboot整合Ehcache就做完了，可以发现一点，原始代码没有任何修改，仅仅是加了一组配置就可以变更缓存供应商了，这也是springboot提供了统一的缓存操作接口的优势，变更实现并不影响原始代码的书写。\n总结\nspringboot使用Ehcache作为缓存实现需要导入Ehcache的坐标 修改设置，配置缓存供应商为ehcache，并提供对应的缓存配置文件 ​\nSpringBoot整合Redis缓存\r​\t上节使用Ehcache替换了springboot内置的缓存技术，其实springboot支持的缓存技术还很多，下面使用redis技术作为缓存解决方案来实现手机验证码案例。\n​\t比对使用Ehcache的过程，加坐标，改缓存实现类型为ehcache，做Ehcache的配置。如果还成redis做缓存呢？一模一样，加坐标，改缓存实现类型为redis，做redis的配置。差别之处只有一点，redis的配置可以在yml文件中直接进行配置，无需制作独立的配置文件。\n步骤①：导入redis的坐标\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-data-redis\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; 步骤②：配置缓存技术实现使用redis\nspring: redis: host: localhost port: 6379 cache: type: redis ​\t如果需要对redis作为缓存进行配置，注意不是对原始的redis进行配置，而是配置redis作为缓存使用相关的配置，隶属于spring.cache.redis节点下，注意不要写错位置了。\nspring: redis: host: localhost port: 6379 cache: type: redis redis: use-key-prefix: false key-prefix: sms_ cache-null-values: false time-to-live: 10s 总结\nspringboot使用redis作为缓存实现需要导入redis的坐标 修改设置，配置缓存供应商为redis，并提供对应的缓存配置 SpringBoot整合Memcached缓存\r​\t目前我们已经掌握了3种缓存解决方案的配置形式，分别是springboot内置缓存，ehcache和redis，本节研究一下国内比较流行的一款缓存memcached。\n​\t按照之前的套路，其实变更缓存并不繁琐，但是springboot并没有支持使用memcached作为其缓存解决方案，也就是说在type属性中没有memcached的配置选项，这里就需要更变一下处理方式了。在整合之前先安装memcached。\n安装\n​\twindows版安装包下载地址：https://www.runoob.com/memcached/window-install-memcached.html\n​\t下载的安装包是解压缩就能使用的zip文件，解压缩完毕后会得到如下文件\n​\t可执行文件只有一个memcached.exe，使用该文件可以将memcached作为系统服务启动，执行此文件时会出现报错信息，如下：\n​\t此处出现问题的原因是注册系统服务时需要使用管理员权限，当前账号权限不足导致安装服务失败，切换管理员账号权限启动命令行\n​\t然后再次执行安装服务的命令即可，如下：\nmemcached.exe -d install ​\t服务安装完毕后可以使用命令启动和停止服务，如下：\nmemcached.exe -d start\t# 启动服务 memcached.exe -d stop\t# 停止服务 ​\t也可以在任务管理器中进行服务状态的切换\n变更缓存为Memcached\n​\t由于memcached未被springboot收录为缓存解决方案，因此使用memcached需要通过手工硬编码的方式来使用，于是前面的套路都不适用了，需要自己写了。\n​\tmemcached目前提供有三种客户端技术，分别是Memcached Client for Java、SpyMemcached和Xmemcached，其中性能指标各方面最好的客户端是Xmemcached，本次整合就使用这个作为客户端实现技术了。下面开始使用Xmemcached\n步骤①：导入xmemcached的坐标\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.googlecode.xmemcached\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;xmemcached\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.4.7\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; 步骤②：配置memcached，制作memcached的配置类\n@Configuration public class XMemcachedConfig { @Bean public MemcachedClient getMemcachedClient() throws IOException { MemcachedClientBuilder memcachedClientBuilder = new XMemcachedClientBuilder(\u0026#34;localhost:11211\u0026#34;); MemcachedClient memcachedClient = memcachedClientBuilder.build(); return memcachedClient; } } ​\tmemcached默认对外服务端口11211。\n步骤③：使用xmemcached客户端操作缓存，注入MemcachedClient对象\n@Service public class SMSCodeServiceImpl implements SMSCodeService { @Autowired private CodeUtils codeUtils; @Autowired private MemcachedClient memcachedClient; public String sendCodeToSMS(String tele) { String code = codeUtils.generator(tele); try { memcachedClient.set(tele,10,code); } catch (Exception e) { e.printStackTrace(); } return code; } public boolean checkCode(SMSCode smsCode) { String code = null; try { code = memcachedClient.get(smsCode.getTele()).toString(); } catch (Exception e) { e.printStackTrace(); } return smsCode.getCode().equals(code); } } ​\t设置值到缓存中使用set操作，取值使用get操作，其实更符合我们开发者的习惯。\n​\t上述代码中对于服务器的配置使用硬编码写死到了代码中，将此数据提取出来，做成独立的配置属性。\n定义配置属性\n​\t以下过程采用前期学习的属性配置方式进行，当前操作有助于理解原理篇中的很多知识。\n定义配置类，加载必要的配置属性，读取配置文件中memcached节点信息\n@Component @ConfigurationProperties(prefix = \u0026#34;memcached\u0026#34;) @Data public class XMemcachedProperties { private String servers; private int poolSize; private long opTimeout; } 定义memcached节点信息\nmemcached: servers: localhost:11211 poolSize: 10 opTimeout: 3000 在memcached配置类中加载信息\n@Configuration public class XMemcachedConfig { @Autowired private XMemcachedProperties props; @Bean public MemcachedClient getMemcachedClient() throws IOException { MemcachedClientBuilder memcachedClientBuilder = new XMemcachedClientBuilder(props.getServers()); memcachedClientBuilder.setConnectionPoolSize(props.getPoolSize()); memcachedClientBuilder.setOpTimeout(props.getOpTimeout()); MemcachedClient memcachedClient = memcachedClientBuilder.build(); return memcachedClient; } } 总结\nmemcached安装后需要启动对应服务才可以对外提供缓存功能，安装memcached服务需要基于windows系统管理员权限 由于springboot没有提供对memcached的缓存整合方案，需要采用手工编码的形式创建xmemcached客户端操作缓存 导入xmemcached坐标后，创建memcached配置类，注册MemcachedClient对应的bean，用于操作缓存 初始化MemcachedClient对象所需要使用的属性可以通过自定义配置属性类的形式加载 思考\n​\t到这里已经完成了三种缓存的整合，其中redis和mongodb需要安装独立的服务器，连接时需要输入对应的服务器地址，这种是远程缓存，Ehcache是一个典型的内存级缓存，因为它什么也不用安装，启动后导入jar包就有缓存功能了。这个时候就要问了，能不能这两种缓存一起用呢？咱们下节再说。\nSpringBoot整合jetcache缓存\r​\t目前我们使用的缓存都是要么A要么B，能不能AB一起用呢？这一节就解决这个问题。springboot针对缓存的整合仅仅停留在用缓存上面，如果缓存自身不支持同时支持AB一起用，springboot也没办法，所以要想解决AB缓存一起用的问题，就必须找一款缓存能够支持AB两种缓存一起用，有这种缓存吗？还真有，阿里出品，jetcache。\n​\tjetcache严格意义上来说，并不是一个缓存解决方案，只能说他算是一个缓存框架，然后把别的缓存放到jetcache中管理，这样就可以支持AB缓存一起用了。并且jetcache参考了springboot整合缓存的思想，整体技术使用方式和springboot的缓存解决方案思想非常类似。下面咱们就先把jetcache用起来，然后再说它里面的一些小的功能。\n​\t做之前要先明确一下，jetcache并不是随便拿两个缓存都能拼到一起去的。目前jetcache支持的缓存方案本地缓存支持两种，远程缓存支持两种，分别如下：\n本地缓存（Local） LinkedHashMap Caffeine 远程缓存（Remote） Redis Tair ​\t其实也有人问我，为什么jetcache只支持2+2这么4款缓存呢？阿里研发这个技术其实主要是为了满足自身的使用需要。最初肯定只有1+1种，逐步变化成2+2种。下面就以LinkedHashMap+Redis的方案实现本地与远程缓存方案同时使用。\n纯远程方案\r步骤①：导入springboot整合jetcache对应的坐标starter，当前坐标默认使用的远程方案是redis\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.alicp.jetcache\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;jetcache-starter-redis\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.6.2\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; 步骤②：远程方案基本配置\njetcache: remote: default: type: redis host: localhost port: 6379 poolConfig: maxTotal: 50 ​\t其中poolConfig是必配项，否则会报错\n步骤③：启用缓存，在引导类上方标注注解@EnableCreateCacheAnnotation配置springboot程序中可以使用注解的形式创建缓存\n@SpringBootApplication //jetcache启用缓存的主开关 @EnableCreateCacheAnnotation public class Springboot20JetCacheApplication { public static void main(String[] args) { SpringApplication.run(Springboot20JetCacheApplication.class, args); } } 步骤④：创建缓存对象Cache，并使用注解@CreateCache标记当前缓存的信息，然后使用Cache对象的API操作缓存，put写缓存，get读缓存。\n@Service public class SMSCodeServiceImpl implements SMSCodeService { @Autowired private CodeUtils codeUtils; @CreateCache(name=\u0026#34;jetCache_\u0026#34;,expire = 10,timeUnit = TimeUnit.SECONDS) private Cache\u0026lt;String ,String\u0026gt; jetCache; public String sendCodeToSMS(String tele) { String code = codeUtils.generator(tele); jetCache.put(tele,code); return code; } public boolean checkCode(SMSCode smsCode) { String code = jetCache.get(smsCode.getTele()); return smsCode.getCode().equals(code); } } ​\t通过上述jetcache使用远程方案连接redis可以看出，jetcache操作缓存时的接口操作更符合开发者习惯，使用缓存就先获取缓存对象Cache，放数据进去就是put，取数据出来就是get，更加简单易懂。并且jetcache操作缓存时，可以为某个缓存对象设置过期时间，将同类型的数据放入缓存中，方便有效周期的管理。\n​\t上述方案中使用的是配置中定义的default缓存，其实这个default是个名字，可以随便写，也可以随便加。例如再添加一种缓存解决方案，参照如下配置进行：\njetcache: remote: default: type: redis host: localhost port: 6379 poolConfig: maxTotal: 50 sms: type: redis host: localhost port: 6379 poolConfig: maxTotal: 50 ​\t如果想使用名称是sms的缓存，需要再创建缓存时指定参数area，声明使用对应缓存即可\n@Service public class SMSCodeServiceImpl implements SMSCodeService { @Autowired private CodeUtils codeUtils; @CreateCache(area=\u0026#34;sms\u0026#34;,name=\u0026#34;jetCache_\u0026#34;,expire = 10,timeUnit = TimeUnit.SECONDS) private Cache\u0026lt;String ,String\u0026gt; jetCache; public String sendCodeToSMS(String tele) { String code = codeUtils.generator(tele); jetCache.put(tele,code); return code; } public boolean checkCode(SMSCode smsCode) { String code = jetCache.get(smsCode.getTele()); return smsCode.getCode().equals(code); } } 纯本地方案\r​\t远程方案中，配置中使用remote表示远程，换成local就是本地，只不过类型不一样而已。\n步骤①：导入springboot整合jetcache对应的坐标starter\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.alicp.jetcache\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;jetcache-starter-redis\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.6.2\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; 步骤②：本地缓存基本配置\njetcache: local: default: type: linkedhashmap keyConvertor: fastjson ​\t为了加速数据获取时key的匹配速度，jetcache要求指定key的类型转换器。简单说就是，如果你给了一个Object作为key的话，我先用key的类型转换器给转换成字符串，然后再保存。等到获取数据时，仍然是先使用给定的Object转换成字符串，然后根据字符串匹配。由于jetcache是阿里的技术，这里推荐key的类型转换器使用阿里的fastjson。\n步骤③：启用缓存\n@SpringBootApplication //jetcache启用缓存的主开关 @EnableCreateCacheAnnotation public class Springboot20JetCacheApplication { public static void main(String[] args) { SpringApplication.run(Springboot20JetCacheApplication.class, args); } } 步骤④：创建缓存对象Cache时，标注当前使用本地缓存\n@Service public class SMSCodeServiceImpl implements SMSCodeService { @CreateCache(name=\u0026#34;jetCache_\u0026#34;,expire = 1000,timeUnit = TimeUnit.SECONDS,cacheType = CacheType.LOCAL) private Cache\u0026lt;String ,String\u0026gt; jetCache; public String sendCodeToSMS(String tele) { String code = codeUtils.generator(tele); jetCache.put(tele,code); return code; } public boolean checkCode(SMSCode smsCode) { String code = jetCache.get(smsCode.getTele()); return smsCode.getCode().equals(code); } } ​\tcacheType控制当前缓存使用本地缓存还是远程缓存，配置cacheType=CacheType.LOCAL即使用本地缓存。\n本地+远程方案\r​\t本地和远程方法都有了，两种方案一起使用如何配置呢？其实就是将两种配置合并到一起就可以了。\njetcache: local: default: type: linkedhashmap keyConvertor: fastjson remote: default: type: redis host: localhost port: 6379 poolConfig: maxTotal: 50 sms: type: redis host: localhost port: 6379 poolConfig: maxTotal: 50 ​\t在创建缓存的时候，配置cacheType为BOTH即则本地缓存与远程缓存同时使用。\n@Service public class SMSCodeServiceImpl implements SMSCodeService { @CreateCache(name=\u0026#34;jetCache_\u0026#34;,expire = 1000,timeUnit = TimeUnit.SECONDS,cacheType = CacheType.BOTH) private Cache\u0026lt;String ,String\u0026gt; jetCache; } ​\tcacheType如果不进行配置，默认值是REMOTE，即仅使用远程缓存方案。关于jetcache的配置，参考以下信息\n属性 默认值 说明 jetcache.statIntervalMinutes 0 统计间隔，0表示不统计 jetcache.hiddenPackages 无 自动生成name时，隐藏指定的包名前缀 jetcache.[local|remote].${area}.type 无 缓存类型，本地支持linkedhashmap、caffeine，远程支持redis、tair jetcache.[local|remote].${area}.keyConvertor 无 key转换器，当前仅支持fastjson jetcache.[local|remote].${area}.valueEncoder java 仅remote类型的缓存需要指定，可选java和kryo jetcache.[local|remote].${area}.valueDecoder java 仅remote类型的缓存需要指定，可选java和kryo jetcache.[local|remote].${area}.limit 100 仅local类型的缓存需要指定，缓存实例最大元素数 jetcache.[local|remote].${area}.expireAfterWriteInMillis 无穷大 默认过期时间，毫秒单位 jetcache.local.${area}.expireAfterAccessInMillis 0 仅local类型的缓存有效，毫秒单位，最大不活动间隔 ​\t以上方案仅支持手工控制缓存，但是springcache方案中的方法缓存特别好用，给一个方法添加一个注解，方法就会自动使用缓存。jetcache也提供了对应的功能，即方法缓存。\n方法缓存\n​\tjetcache提供了方法缓存方案，只不过名称变更了而已。在对应的操作接口上方使用注解@Cached即可\n步骤①：导入springboot整合jetcache对应的坐标starter\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.alicp.jetcache\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;jetcache-starter-redis\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.6.2\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; 步骤②：配置缓存\njetcache: local: default: type: linkedhashmap keyConvertor: fastjson remote: default: type: redis host: localhost port: 6379 keyConvertor: fastjson valueEncode: java valueDecode: java poolConfig: maxTotal: 50 sms: type: redis host: localhost port: 6379 poolConfig: maxTotal: 50 ​\t由于redis缓存中不支持保存对象，因此需要对redis设置当Object类型数据进入到redis中时如何进行类型转换。需要配置keyConvertor表示key的类型转换方式，同时标注value的转换类型方式，值进入redis时是java类型，标注valueEncode为java，值从redis中读取时转换成java，标注valueDecode为java。\n​\t注意，为了实现Object类型的值进出redis，需要保障进出redis的Object类型的数据必须实现序列化接口。\n@Data public class Book implements Serializable { private Integer id; private String type; private String name; private String description; } 步骤③：启用缓存时开启方法缓存功能，并配置basePackages，说明在哪些包中开启方法缓存\n@SpringBootApplication //jetcache启用缓存的主开关 @EnableCreateCacheAnnotation //开启方法注解缓存 @EnableMethodCache(basePackages = \u0026#34;com.itheima\u0026#34;) public class Springboot20JetCacheApplication { public static void main(String[] args) { SpringApplication.run(Springboot20JetCacheApplication.class, args); } } 步骤④：使用注解@Cached标注当前方法使用缓存\n@Service public class BookServiceImpl implements BookService { @Autowired private BookDao bookDao; @Override @Cached(name=\u0026#34;book_\u0026#34;,key=\u0026#34;#id\u0026#34;,expire = 3600,cacheType = CacheType.REMOTE) public Book getById(Integer id) { return bookDao.selectById(id); } } 远程方案的数据同步\r​\t由于远程方案中redis保存的数据可以被多个客户端共享，这就存在了数据同步问题。jetcache提供了3个注解解决此问题，分别在更新、删除操作时同步缓存数据，和读取缓存时定时刷新数据\n更新缓存\n@CacheUpdate(name=\u0026#34;book_\u0026#34;,key=\u0026#34;#book.id\u0026#34;,value=\u0026#34;#book\u0026#34;) public boolean update(Book book) { return bookDao.updateById(book) \u0026gt; 0; } 删除缓存\n@CacheInvalidate(name=\u0026#34;book_\u0026#34;,key = \u0026#34;#id\u0026#34;) public boolean delete(Integer id) { return bookDao.deleteById(id) \u0026gt; 0; } 定时刷新缓存\n@Cached(name=\u0026#34;book_\u0026#34;,key=\u0026#34;#id\u0026#34;,expire = 3600,cacheType = CacheType.REMOTE) @CacheRefresh(refresh = 5) public Book getById(Integer id) { return bookDao.selectById(id); } 数据报表\r​\tjetcache还提供有简单的数据报表功能，帮助开发者快速查看缓存命中信息，只需要添加一个配置即可\njetcache: statIntervalMinutes: 1 ​\t设置后，每1分钟在控制台输出缓存数据命中信息\n[DefaultExecutor] c.alicp.jetcache.support.StatInfoLogger : jetcache stat from 2022-02-28 09:32:15,892 to 2022-02-28 09:33:00,003\rcache | qps| rate| get| hit| fail| expire| avgLoadTime| maxLoadTime\r---------+-------+-------+------+-------+-------+---------+--------------+--------------\rbook_ | 0.66| 75.86%| 29| 22| 0| 0| 28.0| 188\r---------+-------+-------+------+-------+-------+---------+--------------+-------------- 总结\njetcache是一个类似于springcache的缓存解决方案，自身不具有缓存功能，它提供有本地缓存与远程缓存多级共同使用的缓存解决方案 jetcache提供的缓存解决方案受限于目前支持的方案，本地缓存支持两种，远程缓存支持两种 注意数据进入远程缓存时的类型转换问题 jetcache提供方法缓存，并提供了对应的缓存更新与刷新功能 jetcache提供有简单的缓存信息命中报表方便开发者即时监控缓存数据命中情况 思考\n​\tjetcache解决了前期使用缓存方案单一的问题，但是仍然不能灵活的选择缓存进行搭配使用，是否存在一种技术可以灵活的搭配各种各样的缓存使用呢？有，咱们下一节再讲。\nSpringBoot整合j2cache缓存\r​\tjetcache可以在限定范围内构建多级缓存，但是灵活性不足，不能随意搭配缓存，本节介绍一种可以随意搭配缓存解决方案的缓存整合框架，j2cache。下面就来讲解如何使用这种缓存框架，以Ehcache与redis整合为例：\n步骤①：导入j2cache、redis、ehcache坐标\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;net.oschina.j2cache\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;j2cache-core\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.8.4-release\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;net.oschina.j2cache\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;j2cache-spring-boot2-starter\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.8.0-release\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;net.sf.ehcache\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;ehcache\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; ​\tj2cache的starter中默认包含了redis坐标，官方推荐使用redis作为二级缓存，因此此处无需导入redis坐标\n步骤②：配置一级与二级缓存，并配置一二级缓存间数据传递方式，配置书写在名称为j2cache.properties的文件中。如果使用ehcache还需要单独添加ehcache的配置文件\n# 1级缓存 j2cache.L1.provider_class = ehcache ehcache.configXml = ehcache.xml # 2级缓存 j2cache.L2.provider_class = net.oschina.j2cache.cache.support.redis.SpringRedisProvider j2cache.L2.config_section = redis redis.hosts = localhost:6379 # 1级缓存中的数据如何到达二级缓存 j2cache.broadcast = net.oschina.j2cache.cache.support.redis.SpringRedisPubSubPolicy ​\t此处配置不能乱配置，需要参照官方给出的配置说明进行。例如1级供应商选择ehcache，供应商名称仅仅是一个ehcache，但是2级供应商选择redis时要写专用的Spring整合Redis的供应商类名SpringRedisProvider，而且这个名称并不是所有的redis包中能提供的，也不是spring包中提供的。因此配置j2cache必须参照官方文档配置，而且还要去找专用的整合包，导入对应坐标才可以使用。\n​\t一级与二级缓存最重要的一个配置就是两者之间的数据沟通方式，此类配置也不是随意配置的，并且不同的缓存解决方案提供的数据沟通方式差异化很大，需要查询官方文档进行设置。\n步骤③：使用缓存\n@Service public class SMSCodeServiceImpl implements SMSCodeService { @Autowired private CodeUtils codeUtils; @Autowired private CacheChannel cacheChannel; public String sendCodeToSMS(String tele) { String code = codeUtils.generator(tele); cacheChannel.set(\u0026#34;sms\u0026#34;,tele,code); return code; } public boolean checkCode(SMSCode smsCode) { String code = cacheChannel.get(\u0026#34;sms\u0026#34;,smsCode.getTele()).asString(); return smsCode.getCode().equals(code); } } ​\tj2cache的使用和jetcache比较类似，但是无需开启使用的开关，直接定义缓存对象即可使用，缓存对象名CacheChannel。\n​\tj2cache的使用不复杂，配置是j2cache的核心，毕竟是一个整合型的缓存框架。缓存相关的配置过多，可以查阅j2cache-core核心包中的j2cache.properties文件中的说明。如下：\n#J2Cache configuration ######################################### # Cache Broadcast Method # values: # jgroups -\u0026gt; use jgroups\u0026#39;s multicast # redis -\u0026gt; use redis publish/subscribe mechanism (using jedis) # lettuce -\u0026gt; use redis publish/subscribe mechanism (using lettuce, Recommend) # rabbitmq -\u0026gt; use RabbitMQ publisher/consumer mechanism # rocketmq -\u0026gt; use RocketMQ publisher/consumer mechanism # none -\u0026gt; don\u0026#39;t notify the other nodes in cluster # xx.xxxx.xxxx.Xxxxx your own cache broadcast policy classname that implement net.oschina.j2cache.cluster.ClusterPolicy ######################################### j2cache.broadcast = redis # jgroups properties jgroups.channel.name = j2cache jgroups.configXml = /network.xml # RabbitMQ properties rabbitmq.exchange = j2cache rabbitmq.host = localhost rabbitmq.port = 5672 rabbitmq.username = guest rabbitmq.password = guest # RocketMQ properties rocketmq.name = j2cache rocketmq.topic = j2cache # use ; to split multi hosts rocketmq.hosts = 127.0.0.1:9876 ######################################### # Level 1\u0026amp;2 provider # values: # none -\u0026gt; disable this level cache # ehcache -\u0026gt; use ehcache2 as level 1 cache # ehcache3 -\u0026gt; use ehcache3 as level 1 cache # caffeine -\u0026gt; use caffeine as level 1 cache(only in memory) # redis -\u0026gt; use redis as level 2 cache (using jedis) # lettuce -\u0026gt; use redis as level 2 cache (using lettuce) # readonly-redis -\u0026gt; use redis as level 2 cache ,but never write data to it. if use this provider, you must uncomment `j2cache.L2.config_section` to make the redis configurations available. # memcached -\u0026gt; use memcached as level 2 cache (xmemcached), # [classname] -\u0026gt; use custom provider ######################################### j2cache.L1.provider_class = caffeine j2cache.L2.provider_class = redis # When L2 provider isn\u0026#39;t `redis`, using `L2.config_section = redis` to read redis configurations # j2cache.L2.config_section = redis # Enable/Disable ttl in redis cache data (if disabled, the object in redis will never expire, default:true) # NOTICE: redis hash mode (redis.storage = hash) do not support this feature) j2cache.sync_ttl_to_redis = true # Whether to cache null objects by default (default false) j2cache.default_cache_null_object = true ######################################### # Cache Serialization Provider # values: # fst -\u0026gt; using fast-serialization (recommend) # kryo -\u0026gt; using kryo serialization # json -\u0026gt; using fst\u0026#39;s json serialization (testing) # fastjson -\u0026gt; using fastjson serialization (embed non-static class not support) # java -\u0026gt; java standard # fse -\u0026gt; using fse serialization # [classname implements Serializer] ######################################### j2cache.serialization = json #json.map.person = net.oschina.j2cache.demo.Person ######################################### # Ehcache configuration ######################################### # ehcache.configXml = /ehcache.xml # ehcache3.configXml = /ehcache3.xml # ehcache3.defaultHeapSize = 1000 ######################################### # Caffeine configuration # caffeine.region.[name] = size, xxxx[s|m|h|d] # ######################################### caffeine.properties = /caffeine.properties ######################################### # Redis connection configuration ######################################### ######################################### # Redis Cluster Mode # # single -\u0026gt; single redis server # sentinel -\u0026gt; master-slaves servers # cluster -\u0026gt; cluster servers (数据库配置无效，使用 database = 0） # sharded -\u0026gt; sharded servers (密码、数据库必须在 hosts 中指定，且连接池配置无效 ; redis://user:password@127.0.0.1:6379/0） # ######################################### redis.mode = single #redis storage mode (generic|hash) redis.storage = generic ## redis pub/sub channel name redis.channel = j2cache ## redis pub/sub server (using redis.hosts when empty) redis.channel.host = #cluster name just for sharded redis.cluster_name = j2cache ## redis cache namespace optional, default[empty] redis.namespace = ## redis command scan parameter count, default[1000] #redis.scanCount = 1000 ## connection # Separate multiple redis nodes with commas, such as 192.168.0.10:6379,192.168.0.11:6379,192.168.0.12:6379 redis.hosts = 127.0.0.1:6379 redis.timeout = 2000 redis.password = redis.database = 0 redis.ssl = false ## redis pool properties redis.maxTotal = 100 redis.maxIdle = 10 redis.maxWaitMillis = 5000 redis.minEvictableIdleTimeMillis = 60000 redis.minIdle = 1 redis.numTestsPerEvictionRun = 10 redis.lifo = false redis.softMinEvictableIdleTimeMillis = 10 redis.testOnBorrow = true redis.testOnReturn = false redis.testWhileIdle = true redis.timeBetweenEvictionRunsMillis = 300000 redis.blockWhenExhausted = false redis.jmxEnabled = false ######################################### # Lettuce scheme # # redis -\u0026gt; single redis server # rediss -\u0026gt; single redis server with ssl # redis-sentinel -\u0026gt; redis sentinel # redis-cluster -\u0026gt; cluster servers # ######################################### ######################################### # Lettuce Mode # # single -\u0026gt; single redis server # sentinel -\u0026gt; master-slaves servers # cluster -\u0026gt; cluster servers (数据库配置无效，使用 database = 0） # sharded -\u0026gt; sharded servers (密码、数据库必须在 hosts 中指定，且连接池配置无效 ; redis://user:password@127.0.0.1:6379/0） # ######################################### ## redis command scan parameter count, default[1000] #lettuce.scanCount = 1000 lettuce.mode = single lettuce.namespace = lettuce.storage = hash lettuce.channel = j2cache lettuce.scheme = redis lettuce.hosts = 127.0.0.1:6379 lettuce.password = lettuce.database = 0 lettuce.sentinelMasterId = lettuce.maxTotal = 100 lettuce.maxIdle = 10 lettuce.minIdle = 10 # timeout in milliseconds lettuce.timeout = 10000 # redis cluster topology refresh interval in milliseconds lettuce.clusterTopologyRefresh = 3000 ######################################### # memcached server configurations # refer to https://gitee.com/mirrors/XMemcached ######################################### memcached.servers = 127.0.0.1:11211 memcached.username = memcached.password = memcached.connectionPoolSize = 10 memcached.connectTimeout = 1000 memcached.failureMode = false memcached.healSessionInterval = 1000 memcached.maxQueuedNoReplyOperations = 100 memcached.opTimeout = 100 memcached.sanitizeKeys = false 总结\nj2cache是一个缓存框架，自身不具有缓存功能，它提供多种缓存整合在一起使用的方案 j2cache需要通过复杂的配置设置各级缓存，以及缓存之间数据交换的方式 j2cache操作接口通过CacheChannel实现 KF-5-2.任务\r​\tspringboot整合第三方技术第二部分我们来说说任务系统，其实这里说的任务系统指的是定时任务。定时任务是企业级开发中必不可少的组成部分，诸如长周期业务数据的计算，例如年度报表，诸如系统脏数据的处理，再比如系统性能监控报告，还有抢购类活动的商品上架，这些都离不开定时任务。本节将介绍两种不同的定时任务技术。\nQuartz\r​\tQuartz技术是一个比较成熟的定时任务框架，怎么说呢？有点繁琐，用过的都知道，配置略微复杂。springboot对其进行整合后，简化了一系列的配置，将很多配置采用默认设置，这样开发阶段就简化了很多。再学习springboot整合Quartz前先普及几个Quartz的概念。\n工作（Job）：用于定义具体执行的工作 工作明细（JobDetail）：用于描述定时工作相关的信息 触发器（Trigger）：描述了工作明细与调度器的对应关系 调度器（Scheduler）：用于描述触发工作的执行规则，通常使用cron表达式定义规则 ​\t简单说就是你定时干什么事情，这就是工作，工作不可能就是一个简单的方法，还要设置一些明细信息。工作啥时候执行，设置一个调度器，可以简单理解成设置一个工作执行的时间。工作和调度都是独立定义的，它们两个怎么配合到一起呢？用触发器。完了，就这么多。下面开始springboot整合Quartz。\n步骤①：导入springboot整合Quartz的starter\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-quartz\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; 步骤②：定义任务Bean，按照Quartz的开发规范制作，继承QuartzJobBean\npublic class MyQuartz extends QuartzJobBean { @Override protected void executeInternal(JobExecutionContext context) throws JobExecutionException { System.out.println(\u0026#34;quartz task run...\u0026#34;); } } 步骤③：创建Quartz配置类，定义工作明细（JobDetail）与触发器的（Trigger）bean\n@Configuration public class QuartzConfig { @Bean public JobDetail printJobDetail(){ //绑定具体的工作 return JobBuilder.newJob(MyQuartz.class).storeDurably().build(); } @Bean public Trigger printJobTrigger(){ ScheduleBuilder schedBuilder = CronScheduleBuilder.cronSchedule(\u0026#34;0/5 * * * * ?\u0026#34;); //绑定对应的工作明细 return TriggerBuilder.newTrigger().forJob(printJobDetail()).withSchedule(schedBuilder).build(); } } ​\t工作明细中要设置对应的具体工作，使用newJob()操作传入对应的工作任务类型即可。\n​\t触发器需要绑定任务，使用forJob()操作传入绑定的工作明细对象。此处可以为工作明细设置名称然后使用名称绑定，也可以直接调用对应方法绑定。触发器中最核心的规则是执行时间，此处使用调度器定义执行时间，执行时间描述方式使用的是cron表达式。有关cron表达式的规则，各位小伙伴可以去参看相关课程学习，略微复杂，而且格式不能乱设置，不是写个格式就能用的，写不好就会出现冲突问题。\n总结\nspringboot整合Quartz就是将Quartz对应的核心对象交给spring容器管理，包含两个对象，JobDetail和Trigger对象 JobDetail对象描述的是工作的执行信息，需要绑定一个QuartzJobBean类型的对象 Trigger对象定义了一个触发器，需要为其指定绑定的JobDetail是哪个，同时要设置执行周期调度器 思考\n​\t上面的操作看上去不多，但是Quartz将其中的对象划分粒度过细，导致开发的时候有点繁琐，spring针对上述规则进行了简化，开发了自己的任务管理组件——Task，如何用呢？咱们下节再说。\nTask\r​\tspring根据定时任务的特征，将定时任务的开发简化到了极致。怎么说呢？要做定时任务总要告诉容器有这功能吧，然后定时执行什么任务直接告诉对应的bean什么时间执行就行了，就这么简单，一起来看怎么做\n步骤①：开启定时任务功能，在引导类上开启定时任务功能的开关，使用注解@EnableScheduling\n@SpringBootApplication //开启定时任务功能 @EnableScheduling public class Springboot22TaskApplication { public static void main(String[] args) { SpringApplication.run(Springboot22TaskApplication.class, args); } } 步骤②：定义Bean，在对应要定时执行的操作上方，使用注解@Scheduled定义执行的时间，执行时间的描述方式还是cron表达式\n@Component public class MyBean { @Scheduled(cron = \u0026#34;0/1 * * * * ?\u0026#34;) public void print(){ System.out.println(Thread.currentThread().getName()+\u0026#34; :spring task run...\u0026#34;); } } ​\t完事，这就完成了定时任务的配置。总体感觉其实什么东西都没少，只不过没有将所有的信息都抽取成bean，而是直接使用注解绑定定时执行任务的事情而已。\n​\t如何想对定时任务进行相关配置，可以通过配置文件进行\nspring: task: scheduling: pool: size: 1\t# 任务调度线程池大小 默认 1 thread-name-prefix: ssm_ # 调度线程名称前缀 默认 scheduling- shutdown: await-termination: false\t# 线程池关闭时等待所有任务完成 await-termination-period: 10s\t# 调度线程关闭前最大等待时间，确保最后一定关闭 总结\nspring task需要使用注解@EnableScheduling开启定时任务功能\n为定时执行的的任务设置执行周期，描述方式cron表达式\nKF-5-3.邮件\r​\tspringboot整合第三方技术第三部分我们来说说邮件系统，发邮件是java程序的基本操作，springboot整合javamail其实就是简化开发。不熟悉邮件的小伙伴可以先学习完javamail的基础操作，再来看这一部分内容才能感触到springboot整合javamail究竟简化了哪些操作。简化的多码？其实不多，差别不大，只是还个格式而已。\n​\t学习邮件发送之前先了解3个概念，这些概念规范了邮件操作过程中的标准。\nSMTP（Simple Mail Transfer Protocol）：简单邮件传输协议，用于发送电子邮件的传输协议 POP3（Post Office Protocol - Version 3）：用于接收电子邮件的标准协议 IMAP（Internet Mail Access Protocol）：互联网消息协议，是POP3的替代协议 ​\t简单说就是SMPT是发邮件的标准，POP3是收邮件的标准，IMAP是对POP3的升级。我们制作程序中操作邮件，通常是发邮件，所以SMTP是使用的重点，收邮件大部分都是通过邮件客户端完成，所以开发收邮件的代码极少。除非你要读取邮件内容，然后解析，做邮件功能的统一处理。例如HR的邮箱收到求职者的简历，可以读取后统一处理。但是为什么不制作独立的投递简历的系统呢？所以说，好奇怪的需求，因为要想收邮件就要规范发邮件的人的书写格式，这个未免有点强人所难，并且极易收到外部攻击，你不可能使用白名单来收邮件。如果能使用白名单来收邮件然后解析邮件，还不如开发个系统给白名单中的人专用呢，更安全，总之就是鸡肋了。下面就开始学习springboot如何整合javamail发送邮件。\n发送简单邮件\r步骤①：导入springboot整合javamail的starter\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-mail\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; 步骤②：配置邮箱的登录信息\nspring: mail: host: smtp.126.com username: test@126.com password: test ​\tjava程序仅用于发送邮件，邮件的功能还是邮件供应商提供的，所以这里是用别人的邮件服务，要配置对应信息。\n​\thost配置的是提供邮件服务的主机协议，当前程序仅用于发送邮件，因此配置的是smtp的协议。\n​\tpassword并不是邮箱账号的登录密码，是邮件供应商提供的一个加密后的密码，也是为了保障系统安全性。不然外部人员通过地址访问下载了配置文件，直接获取到了邮件密码就会有极大的安全隐患。有关该密码的获取每个邮件供应商提供的方式都不一样，此处略过。可以到邮件供应商的设置页面找POP3或IMAP这些关键词找到对应的获取位置。下例仅供参考：\n步骤③：使用JavaMailSender接口发送邮件\n@Service public class SendMailServiceImpl implements SendMailService { @Autowired private JavaMailSender javaMailSender; //发送人 private String from = \u0026#34;test@qq.com\u0026#34;; //接收人 private String to = \u0026#34;test@126.com\u0026#34;; //标题 private String subject = \u0026#34;测试邮件\u0026#34;; //正文 private String context = \u0026#34;测试邮件正文内容\u0026#34;; @Override public void sendMail() { SimpleMailMessage message = new SimpleMailMessage(); message.setFrom(from+\u0026#34;(小甜甜)\u0026#34;); message.setTo(to); message.setSubject(subject); message.setText(context); javaMailSender.send(message); } } ​\t将发送邮件的必要信息（发件人、收件人、标题、正文）封装到SimpleMailMessage对象中，可以根据规则设置发送人昵称等。\n发送多组件邮件（附件、复杂正文）\r​\t发送简单邮件仅需要提供对应的4个基本信息就可以了，如果想发送复杂的邮件，需要更换邮件对象。使用MimeMessage可以发送特殊的邮件。\n发送网页正文邮件\n@Service public class SendMailServiceImpl2 implements SendMailService { @Autowired private JavaMailSender javaMailSender; //发送人 private String from = \u0026#34;test@qq.com\u0026#34;; //接收人 private String to = \u0026#34;test@126.com\u0026#34;; //标题 private String subject = \u0026#34;测试邮件\u0026#34;; //正文 private String context = \u0026#34;\u0026lt;img src=\u0026#39;ABC.JPG\u0026#39;/\u0026gt;\u0026lt;a href=\u0026#39;https://www.itcast.cn\u0026#39;\u0026gt;点开有惊喜\u0026lt;/a\u0026gt;\u0026#34;; public void sendMail() { try { MimeMessage message = javaMailSender.createMimeMessage(); MimeMessageHelper helper = new MimeMessageHelper(message); helper.setFrom(to+\u0026#34;(小甜甜)\u0026#34;); helper.setTo(from); helper.setSubject(subject); helper.setText(context,true);\t//此处设置正文支持html解析 javaMailSender.send(message); } catch (Exception e) { e.printStackTrace(); } } } 发送带有附件的邮件\n@Service public class SendMailServiceImpl2 implements SendMailService { @Autowired private JavaMailSender javaMailSender; //发送人 private String from = \u0026#34;test@qq.com\u0026#34;; //接收人 private String to = \u0026#34;test@126.com\u0026#34;; //标题 private String subject = \u0026#34;测试邮件\u0026#34;; //正文 private String context = \u0026#34;测试邮件正文\u0026#34;; public void sendMail() { try { MimeMessage message = javaMailSender.createMimeMessage(); MimeMessageHelper helper = new MimeMessageHelper(message,true);\t//此处设置支持附件 helper.setFrom(to+\u0026#34;(小甜甜)\u0026#34;); helper.setTo(from); helper.setSubject(subject); helper.setText(context); //添加附件 File f1 = new File(\u0026#34;springboot_23_mail-0.0.1-SNAPSHOT.jar\u0026#34;); File f2 = new File(\u0026#34;resources\\\\logo.png\u0026#34;); helper.addAttachment(f1.getName(),f1); helper.addAttachment(\u0026#34;最靠谱的培训结构.png\u0026#34;,f2); javaMailSender.send(message); } catch (Exception e) { e.printStackTrace(); } } } 总结\nspringboot整合javamail其实就是简化了发送邮件的客户端对象JavaMailSender的初始化过程，通过配置的形式加载信息简化开发过程 KF-5-4.消息\r​\tspringboot整合第三方技术最后一部分我们来说说消息中间件，首先先介绍一下消息的应用。\n消息的概念\r​\t从广义角度来说，消息其实就是信息，但是和信息又有所不同。信息通常被定义为一组数据，而消息除了具有数据的特征之外，还有消息的来源与接收的概念。通常发送消息的一方称为消息的生产者，接收消息的一方称为消息的消费者。这样比较后，发现其实消息和信息差别还是很大的。\n​\t为什么要设置生产者和消费者呢？这就是要说到消息的意义了。信息通常就是一组数据，但是消息由于有了生产者和消费者，就出现了消息中所包含的信息可以被二次解读，生产者发送消息，可以理解为生产者发送了一个信息，也可以理解为生产者发送了一个命令；消费者接收消息，可以理解为消费者得到了一个信息，也可以理解为消费者得到了一个命令。对比一下我们会发现信息是一个基本数据，而命令则可以关联下一个行为动作，这样就可以理解为基于接收的消息相当于得到了一个行为动作，使用这些行为动作就可以组织成一个业务逻辑，进行进一步的操作。总的来说，消息其实也是一组信息，只是为其赋予了全新的含义，因为有了消息的流动，并且是有方向性的流动，带来了基于流动的行为产生的全新解读。开发者就可以基于消息的这种特殊解，将其换成代码中的指令。\n​\t对于消息的理解，初学者总认为消息内部的数据非常复杂，这是一个误区。比如我发送了一个消息，要求接受者翻译发送过去的内容。初学者会认为消息中会包含被翻译的文字，已经本次操作要执行翻译操作而不是打印操作。其实这种现象有点过度解读了，发送的消息中仅仅包含被翻译的文字，但是可以通过控制不同的人接收此消息来确认要做的事情。例如发送被翻译的文字仅到A程序，而A程序只能进行翻译操作，这样就可以发送简单的信息完成复杂的业务了，是通过接收消息的主体不同，进而执行不同的操作，而不会在消息内部定义数据的操作行为，当然如果开发者希望消息中包含操作种类信息也是可以的，只是提出消息的内容可以更简单，更单一。\n​\t对于消息的生产者与消费者的工作模式，还可以将消息划分成两种模式，同步消费与异步消息。\n​\t所谓同步消息就是生产者发送完消息，等待消费者处理，消费者处理完将结果告知生产者，然后生产者继续向下执行业务。这种模式过于卡生产者的业务执行连续性，在现在的企业级开发中，上述这种业务场景通常不会采用消息的形式进行处理。\n​\t所谓异步消息就是生产者发送完消息，无需等待消费者处理完毕，生产者继续向下执行其他动作。比如生产者发送了一个日志信息给日志系统，发送过去以后生产者就向下做其他事情了，无需关注日志系统的执行结果。日志系统根据接收到的日志信息继续进行业务执行，是单纯的记录日志，还是记录日志并报警，这些和生产者无关，这样生产者的业务执行效率就会大幅度提升。并且可以通过添加多个消费者来处理同一个生产者发送的消息来提高系统的高并发性，改善系统工作效率，提高用户体验。一旦某一个消费者由于各种问题宕机了，也不会对业务产生影响，提高了系统的高可用性。\n​\t以上简单的介绍了一下消息这种工作模式存在的意义，希望对各位学习者有所帮助。\nJava处理消息的标准规范\r​\t目前企业级开发中广泛使用的消息处理技术共三大类，具体如下：\nJMS AMQP MQTT ​\t为什么是三大类，而不是三个技术呢？因为这些都是规范，就想JDBC技术，是个规范，开发针对规范开发，运行还要靠实现类，例如MySQL提供了JDBC的实现，最终运行靠的还是实现。并且这三类规范都是针对异步消息进行处理的，也符合消息的设计本质，处理异步的业务。对以上三种消息规范做一下普及\nJMS\r​\tJMS（Java Message Service）,这是一个规范，作用等同于JDBC规范，提供了与消息服务相关的API接口。\nJMS消息模型\n​\tJMS规范中规范了消息有两种模型。分别是点对点模型和发布订阅模型。\n​\t点对点模型：peer-2-peer，生产者会将消息发送到一个保存消息的容器中，通常使用队列模型，使用队列保存消息。一个队列的消息只能被一个消费者消费，或未被及时消费导致超时。这种模型下，生产者和消费者是一对一绑定的。\n​\t发布订阅模型：publish-subscribe，生产者将消息发送到一个保存消息的容器中，也是使用队列模型来保存。但是消息可以被多个消费者消费，生产者和消费者完全独立，相互不需要感知对方的存在。\n​\t以上这种分类是从消息的生产和消费过程来进行区分，针对消息所包含的信息不同，还可以进行不同类别的划分。\nJMS消息种类\n​\t根据消息中包含的数据种类划分，可以将消息划分成6种消息。\nTextMessage MapMessage BytesMessage StreamMessage ObjectMessage Message （只有消息头和属性） ​\tJMS主张不同种类的消息，消费方式不同，可以根据使用需要选择不同种类的消息。但是这一点也成为其诟病之处，后面再说。整体上来说，JMS就是典型的保守派，什么都按照J2EE的规范来，做一套规范，定义若干个标准，每个标准下又提供一大批API。目前对JMS规范实现的消息中间件技术还是挺多的，毕竟是皇家御用，肯定有人舔，例如ActiveMQ、Redis、HornetMQ。但是也有一些不太规范的实现，参考JMS的标准设计，但是又不完全满足其规范，例如：RabbitMQ、RocketMQ。\nAMQP\r​\tJMS的问世为消息中间件提供了很强大的规范性支撑，但是使用的过程中就开始被人诟病，比如JMS设置的极其复杂的多种类消息处理机制。本来分门别类处理挺好的，为什么会被诟病呢？原因就在于JMS的设计是J2EE规范，站在Java开发的角度思考问题。但是现实往往是复杂度很高的。比如我有一个.NET开发的系统A，有一个Java开发的系统B，现在要从A系统给B系统发业务消息，结果两边数据格式不统一，没法操作。JMS不是可以统一数据格式吗？提供了6种数据种类，总有一款适合你啊。NO，一个都不能用。因为A系统的底层语言不是Java语言开发的，根本不支持那些对象。这就意味着如果想使用现有的业务系统A继续开发已经不可能了，必须推翻重新做使用Java语言开发的A系统。\n​\t这时候有人就提出说，你搞那么复杂，整那么多种类干什么？找一种大家都支持的消息数据类型不就解决这个跨平台的问题了吗？大家一想，对啊，于是AMQP孕育而生。\n​\t单从上面的说明中其实可以明确感知到，AMQP的出现解决的是消息传递时使用的消息种类的问题，化繁为简，但是其并没有完全推翻JMS的操作API，所以说AMQP仅仅是一种协议，规范了数据传输的格式而已。\n​\tAMQP（advanced message queuing protocol）：一种协议（高级消息队列协议，也是消息代理规范），规范了网络交换的数据格式，兼容JMS操作。 优点\n​\t具有跨平台性，服务器供应商，生产者，消费者可以使用不同的语言来实现\nJMS消息种类\n​\tAMQP消息种类：byte[]\n​\tAMQP在JMS的消息模型基础上又进行了进一步的扩展，除了点对点和发布订阅的模型，开发了几种全新的消息模型，适应各种各样的消息发送。\nAMQP消息模型\ndirect exchange fanout exchange topic exchange headers exchange system exchange ​\t目前实现了AMQP协议的消息中间件技术也很多，而且都是较为流行的技术，例如：RabbitMQ、StormMQ、RocketMQ\nMQTT\r​\tMQTT（Message Queueing Telemetry Transport）消息队列遥测传输，专为小设备设计，是物联网（IOT）生态系统中主要成分之一。由于与JavaEE企业级开发没有交集，此处不作过多的说明。\n​\t除了上述3种J2EE企业级应用中广泛使用的三种异步消息传递技术，还有一种技术也不能忽略，Kafka。\nKafKa\r​\tKafka，一种高吞吐量的分布式发布订阅消息系统，提供实时消息功能。Kafka技术并不是作为消息中间件为主要功能的产品，但是其拥有发布订阅的工作模式，也可以充当消息中间件来使用，而且目前企业级开发中其身影也不少见。\n​\t本节内容讲围绕着上述内容中的几种实现方案讲解springboot整合各种各样的消息中间件。由于各种消息中间件必须先安装再使用，下面的内容采用Windows系统安装，降低各位学习者的学习难度，基本套路和之前学习NoSQL解决方案一样，先安装再整合。\n购物订单发送手机短信案例\r​\t为了便于下面演示各种各样的消息中间件技术，我们创建一个购物过程生成订单时为用户发送短信的案例环境，模拟使用消息中间件实现发送手机短信的过程。\n​\t手机验证码案例需求如下：\n执行下单业务时（模拟此过程），调用消息服务，将要发送短信的订单id传递给消息中间件\n消息处理服务接收到要发送的订单id后输出订单id（模拟发短信）\n由于不涉及数据读写，仅开发业务层与表现层，其中短信处理的业务代码独立开发，代码如下：\n订单业务\n​\t业务层接口\npublic interface OrderService { void order(String id); } ​\t模拟传入订单id，执行下订单业务，参数为虚拟设定，实际应为订单对应的实体类\n​\t业务层实现\n@Service public class OrderServiceImpl implements OrderService { @Autowired private MessageService messageService; @Override public void order(String id) { //一系列操作，包含各种服务调用，处理各种业务 System.out.println(\u0026#34;订单处理开始\u0026#34;); //短信消息处理 messageService.sendMessage(id); System.out.println(\u0026#34;订单处理结束\u0026#34;); System.out.println(); } } ​\t业务层转调短信处理的服务MessageService\n​\t表现层服务\n@RestController @RequestMapping(\u0026#34;/orders\u0026#34;) public class OrderController { @Autowired private OrderService orderService; @PostMapping(\u0026#34;{id}\u0026#34;) public void order(@PathVariable String id){ orderService.order(id); } } ​\t表现层对外开发接口，传入订单id即可（模拟）\n短信处理业务\n​\t业务层接口\npublic interface MessageService { void sendMessage(String id); String doMessage(); } ​\t短信处理业务层接口提供两个操作，发送要处理的订单id到消息中间件，另一个操作目前暂且设计成处理消息，实际消息的处理过程不应该是手动执行，应该是自动执行，到具体实现时再进行设计\n​\t业务层实现\n@Service public class MessageServiceImpl implements MessageService { private ArrayList\u0026lt;String\u0026gt; msgList = new ArrayList\u0026lt;String\u0026gt;(); @Override public void sendMessage(String id) { System.out.println(\u0026#34;待发送短信的订单已纳入处理队列，id：\u0026#34;+id); msgList.add(id); } @Override public String doMessage() { String id = msgList.remove(0); System.out.println(\u0026#34;已完成短信发送业务，id：\u0026#34;+id); return id; } } ​\t短信处理业务层实现中使用集合先模拟消息队列，观察效果\n​\t表现层服务\n@RestController @RequestMapping(\u0026#34;/msgs\u0026#34;) public class MessageController { @Autowired private MessageService messageService; @GetMapping public String doMessage(){ String id = messageService.doMessage(); return id; } } ​\t短信处理表现层接口暂且开发出一个处理消息的入口，但是此业务是对应业务层中设计的模拟接口，实际业务不需要设计此接口。\n​\t下面开启springboot整合各种各样的消息中间件，从严格满足JMS规范的ActiveMQ开始\nSpringBoot整合ActiveMQ\r​\tActiveMQ是MQ产品中的元老级产品，早期标准MQ产品之一，在AMQP协议没有出现之前，占据了消息中间件市场的绝大部分份额，后期因为AMQP系列产品的出现，迅速走弱，目前仅在一些线上运行的产品中出现，新产品开发较少采用。\n安装\r​\twindows版安装包下载地址：https://activemq.apache.org/components/classic/download/\n​\t下载的安装包是解压缩就能使用的zip文件，解压缩完毕后会得到如下文件\n启动服务器\nactivemq.bat ​\t运行bin目录下的win32或win64目录下的activemq.bat命令即可，根据自己的操作系统选择即可，默认对外服务端口61616。\n访问web管理服务\n​\tActiveMQ启动后会启动一个Web控制台服务，可以通过该服务管理ActiveMQ。\nhttp://127.0.0.1:8161/ ​\tweb管理服务默认端口8161，访问后可以打开ActiveMQ的管理界面，如下：\n​\t首先输入访问用户名和密码，初始化用户名和密码相同，均为：admin，成功登录后进入管理后台界面，如下：\n​\t看到上述界面视为启动ActiveMQ服务成功。\n启动失败\n​\t在ActiveMQ启动时要占用多个端口，以下为正常启动信息：\nwrapper | --\u0026gt; Wrapper Started as Console\rwrapper | Launching a JVM...\rjvm 1 | Wrapper (Version 3.2.3) http://wrapper.tanukisoftware.org\rjvm 1 | Copyright 1999-2006 Tanuki Software, Inc. All Rights Reserved.\rjvm 1 |\rjvm 1 | Java Runtime: Oracle Corporation 1.8.0_172 D:\\soft\\jdk1.8.0_172\\jre\rjvm 1 | Heap sizes: current=249344k free=235037k max=932352k\rjvm 1 | JVM args: -Dactivemq.home=../.. -Dactivemq.base=../.. -Djavax.net.ssl.keyStorePassword=password -Djavax.net.ssl.trustStorePassword=password -Djavax.net.ssl.keyStore=../../conf/broker.ks -Djavax.net.ssl.trustStore=../../conf/broker.ts -Dcom.sun.management.jmxremote -Dorg.apache.activemq.UseDedicatedTaskRunner=true -Djava.util.logging.config.file=logging.properties -Dactivemq.conf=../../conf -Dactivemq.data=../../data -Djava.security.auth.login.config=../../conf/login.config -Xmx1024m -Djava.library.path=../../bin/win64 -Dwrapper.key=7ySrCD75XhLCpLjd -Dwrapper.port=32000 -Dwrapper.jvm.port.min=31000 -Dwrapper.jvm.port.max=31999 -Dwrapper.pid=9364 -Dwrapper.version=3.2.3 -Dwrapper.native_library=wrapper -Dwrapper.cpu.timeout=10 -Dwrapper.jvmid=1\rjvm 1 | Extensions classpath:\rjvm 1 | [..\\..\\lib,..\\..\\lib\\camel,..\\..\\lib\\optional,..\\..\\lib\\web,..\\..\\lib\\extra]\rjvm 1 | ACTIVEMQ_HOME: ..\\..\rjvm 1 | ACTIVEMQ_BASE: ..\\..\rjvm 1 | ACTIVEMQ_CONF: ..\\..\\conf\rjvm 1 | ACTIVEMQ_DATA: ..\\..\\data\rjvm 1 | Loading message broker from: xbean:activemq.xml\rjvm 1 | INFO | Refreshing org.apache.activemq.xbean.XBeanBrokerFactory$1@5f3ebfe0: startup date [Mon Feb 28 16:07:48 CST 2022]; root of context hierarchy\rjvm 1 | INFO | Using Persistence Adapter: KahaDBPersistenceAdapter[D:\\soft\\activemq\\bin\\win64\\..\\..\\data\\kahadb]\rjvm 1 | INFO | KahaDB is version 7\rjvm 1 | INFO | PListStore:[D:\\soft\\activemq\\bin\\win64\\..\\..\\data\\localhost\\tmp_storage] started\rjvm 1 | INFO | Apache ActiveMQ 5.16.3 (localhost, ID:CZBK-20210302VL-10434-1646035669595-0:1) is starting\rjvm 1 | INFO | Listening for connections at: tcp://CZBK-20210302VL:61616?maximumConnections=1000\u0026amp;wireFormat.maxFrameSize=104857600\rjvm 1 | INFO | Connector openwire started\rjvm 1 | INFO | Listening for connections at: amqp://CZBK-20210302VL:5672?maximumConnections=1000\u0026amp;wireFormat.maxFrameSize=104857600\rjvm 1 | INFO | Connector amqp started\rjvm 1 | INFO | Listening for connections at: stomp://CZBK-20210302VL:61613?maximumConnections=1000\u0026amp;wireFormat.maxFrameSize=104857600\rjvm 1 | INFO | Connector stomp started\rjvm 1 | INFO | Listening for connections at: mqtt://CZBK-20210302VL:1883?maximumConnections=1000\u0026amp;wireFormat.maxFrameSize=104857600\rjvm 1 | INFO | Connector mqtt started\rjvm 1 | INFO | Starting Jetty server\rjvm 1 | INFO | Creating Jetty connector\rjvm 1 | WARN | ServletContext@o.e.j.s.ServletContextHandler@7350746f{/,null,STARTING} has uncovered http methods for path: /\rjvm 1 | INFO | Listening for connections at ws://CZBK-20210302VL:61614?maximumConnections=1000\u0026amp;wireFormat.maxFrameSize=104857600\rjvm 1 | INFO | Connector ws started\rjvm 1 | INFO | Apache ActiveMQ 5.16.3 (localhost, ID:CZBK-20210302VL-10434-1646035669595-0:1) started\rjvm 1 | INFO | For help or more information please see: http://activemq.apache.org\rjvm 1 | WARN | Store limit is 102400 mb (current store usage is 0 mb). The data directory: D:\\soft\\activemq\\bin\\win64\\..\\..\\data\\kahadb only has 68936 mb of usable space. - resetting to maximum available disk space: 68936 mb\rjvm 1 | INFO | ActiveMQ WebConsole available at http://127.0.0.1:8161/\rjvm 1 | INFO | ActiveMQ Jolokia REST API available at http://127.0.0.1:8161/api/jolokia/ ​\t其中占用的端口有：61616、5672、61613、1883、61614，如果启动失败，请先管理对应端口即可。以下就是某个端口占用的报错信息，可以从抛出异常的位置看出，启动5672端口时端口被占用，显示java.net.BindException: Address already in use: JVM_Bind。Windows系统中终止端口运行的操作参看【命令行启动常见问题及解决方案】\nwrapper | --\u0026gt; Wrapper Started as Console\rwrapper | Launching a JVM...\rjvm 1 | Wrapper (Version 3.2.3) http://wrapper.tanukisoftware.org\rjvm 1 | Copyright 1999-2006 Tanuki Software, Inc. All Rights Reserved.\rjvm 1 |\rjvm 1 | Java Runtime: Oracle Corporation 1.8.0_172 D:\\soft\\jdk1.8.0_172\\jre\rjvm 1 | Heap sizes: current=249344k free=235038k max=932352k\rjvm 1 | JVM args: -Dactivemq.home=../.. -Dactivemq.base=../.. -Djavax.net.ssl.keyStorePassword=password -Djavax.net.ssl.trustStorePassword=password -Djavax.net.ssl.keyStore=../../conf/broker.ks -Djavax.net.ssl.trustStore=../../conf/broker.ts -Dcom.sun.management.jmxremote -Dorg.apache.activemq.UseDedicatedTaskRunner=true -Djava.util.logging.config.file=logging.properties -Dactivemq.conf=../../conf -Dactivemq.data=../../data -Djava.security.auth.login.config=../../conf/login.config -Xmx1024m -Djava.library.path=../../bin/win64 -Dwrapper.key=QPJoy9ZoXeWmmwTS -Dwrapper.port=32000 -Dwrapper.jvm.port.min=31000 -Dwrapper.jvm.port.max=31999 -Dwrapper.pid=14836 -Dwrapper.version=3.2.3 -Dwrapper.native_library=wrapper -Dwrapper.cpu.timeout=10 -Dwrapper.jvmid=1\rjvm 1 | Extensions classpath:\rjvm 1 | [..\\..\\lib,..\\..\\lib\\camel,..\\..\\lib\\optional,..\\..\\lib\\web,..\\..\\lib\\extra]\rjvm 1 | ACTIVEMQ_HOME: ..\\..\rjvm 1 | ACTIVEMQ_BASE: ..\\..\rjvm 1 | ACTIVEMQ_CONF: ..\\..\\conf\rjvm 1 | ACTIVEMQ_DATA: ..\\..\\data\rjvm 1 | Loading message broker from: xbean:activemq.xml\rjvm 1 | INFO | Refreshing org.apache.activemq.xbean.XBeanBrokerFactory$1@2c9392f5: startup date [Mon Feb 28 16:06:16 CST 2022]; root of context hierarchy\rjvm 1 | INFO | Using Persistence Adapter: KahaDBPersistenceAdapter[D:\\soft\\activemq\\bin\\win64\\..\\..\\data\\kahadb]\rjvm 1 | INFO | KahaDB is version 7\rjvm 1 | INFO | PListStore:[D:\\soft\\activemq\\bin\\win64\\..\\..\\data\\localhost\\tmp_storage] started\rjvm 1 | INFO | Apache ActiveMQ 5.16.3 (localhost, ID:CZBK-20210302VL-10257-1646035577620-0:1) is starting\rjvm 1 | INFO | Listening for connections at: tcp://CZBK-20210302VL:61616?maximumConnections=1000\u0026amp;wireFormat.maxFrameSize=104857600\rjvm 1 | INFO | Connector openwire started\rjvm 1 | ERROR | Failed to start Apache ActiveMQ (localhost, ID:CZBK-20210302VL-10257-1646035577620-0:1)\rjvm 1 | java.io.IOException: Transport Connector could not be registered in JMX: java.io.IOException: Failed to bind to server socket: amqp://0.0.0.0:5672?maximumConnections=1000\u0026amp;wireFormat.maxFrameSize=104857600 due to: java.net.BindException: Address already in use: JVM_Bind\rjvm 1 | at org.apache.activemq.util.IOExceptionSupport.create(IOExceptionSupport.java:28)\rjvm 1 | at org.apache.activemq.broker.BrokerService.registerConnectorMBean(BrokerService.java:2288)\rjvm 1 | at org.apache.activemq.broker.BrokerService.startTransportConnector(BrokerService.java:2769)\rjvm 1 | at org.apache.activemq.broker.BrokerService.startAllConnectors(BrokerService.java:2665)\rjvm 1 | at org.apache.activemq.broker.BrokerService.doStartBroker(BrokerService.java:780)\rjvm 1 | at org.apache.activemq.broker.BrokerService.startBroker(BrokerService.java:742)\rjvm 1 | at org.apache.activemq.broker.BrokerService.start(BrokerService.java:645)\rjvm 1 | at org.apache.activemq.xbean.XBeanBrokerService.afterPropertiesSet(XBeanBrokerService.java:73)\rjvm 1 | at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)\rjvm 1 | at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)\rjvm 1 | at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)\rjvm 1 | at java.lang.reflect.Method.invoke(Method.java:498)\rjvm 1 | at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.invokeCustomInitMethod(AbstractAutowireCapableBeanFactory.java:1748)\rjvm 1 | at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.invokeInitMethods(AbstractAutowireCapableBeanFactory.java:1685)\rjvm 1 | at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1615)\rjvm 1 | at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:553)\rjvm 1 | at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:481)\rjvm 1 | at org.springframework.beans.factory.support.AbstractBeanFactory$1.getObject(AbstractBeanFactory.java:312)\rjvm 1 | at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:230)\rjvm 1 | at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:308)\rjvm 1 | at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:197)\rjvm 1 | at org.springframework.beans.factory.support.DefaultListableBeanFactory.preInstantiateSingletons(DefaultListableBeanFactory.java:756)\rjvm 1 | at org.springframework.context.support.AbstractApplicationContext.finishBeanFactoryInitialization(AbstractApplicationContext.java:867)\rjvm 1 | at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:542)\rjvm 1 | at org.apache.xbean.spring.context.ResourceXmlApplicationContext.\u0026lt;init\u0026gt;(ResourceXmlApplicationContext.java:64)\rjvm 1 | at org.apache.xbean.spring.context.ResourceXmlApplicationContext.\u0026lt;init\u0026gt;(ResourceXmlApplicationContext.java:52)\rjvm 1 | at org.apache.activemq.xbean.XBeanBrokerFactory$1.\u0026lt;init\u0026gt;(XBeanBrokerFactory.java:104)\rjvm 1 | at org.apache.activemq.xbean.XBeanBrokerFactory.createApplicationContext(XBeanBrokerFactory.java:104)\rjvm 1 | at org.apache.activemq.xbean.XBeanBrokerFactory.createBroker(XBeanBrokerFactory.java:67)\rjvm 1 | at org.apache.activemq.broker.BrokerFactory.createBroker(BrokerFactory.java:71)\rjvm 1 | at org.apache.activemq.broker.BrokerFactory.createBroker(BrokerFactory.java:54)\rjvm 1 | at org.apache.activemq.console.command.StartCommand.runTask(StartCommand.java:87)\rjvm 1 | at org.apache.activemq.console.command.AbstractCommand.execute(AbstractCommand.java:63)\rjvm 1 | at org.apache.activemq.console.command.ShellCommand.runTask(ShellCommand.java:154)\rjvm 1 | at org.apache.activemq.console.command.AbstractCommand.execute(AbstractCommand.java:63)\rjvm 1 | at org.apache.activemq.console.command.ShellCommand.main(ShellCommand.java:104)\rjvm 1 | at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)\rjvm 1 | at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)\rjvm 1 | at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)\rjvm 1 | at java.lang.reflect.Method.invoke(Method.java:498)\rjvm 1 | at org.apache.activemq.console.Main.runTaskClass(Main.java:262)\rjvm 1 | at org.apache.activemq.console.Main.main(Main.java:115)\rjvm 1 | at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)\rjvm 1 | at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)\rjvm 1 | at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)\rjvm 1 | at java.lang.reflect.Method.invoke(Method.java:498)\rjvm 1 | at org.tanukisoftware.wrapper.WrapperSimpleApp.run(WrapperSimpleApp.java:240)\rjvm 1 | at java.lang.Thread.run(Thread.java:748)\rjvm 1 | Caused by: java.io.IOException: Failed to bind to server socket: amqp://0.0.0.0:5672?maximumConnections=1000\u0026amp;wireFormat.maxFrameSize=104857600 due to: java.net.BindException: Address already in use: JVM_Bind\rjvm 1 | at org.apache.activemq.util.IOExceptionSupport.create(IOExceptionSupport.java:34)\rjvm 1 | at org.apache.activemq.transport.tcp.TcpTransportServer.bind(TcpTransportServer.java:146)\rjvm 1 | at org.apache.activemq.transport.tcp.TcpTransportFactory.doBind(TcpTransportFactory.java:62)\rjvm 1 | at org.apache.activemq.transport.TransportFactorySupport.bind(TransportFactorySupport.java:40)\rjvm 1 | at org.apache.activemq.broker.TransportConnector.createTransportServer(TransportConnector.java:335)\rjvm 1 | at org.apache.activemq.broker.TransportConnector.getServer(TransportConnector.java:145)\rjvm 1 | at org.apache.activemq.broker.TransportConnector.asManagedConnector(TransportConnector.java:110)\rjvm 1 | at org.apache.activemq.broker.BrokerService.registerConnectorMBean(BrokerService.java:2283)\rjvm 1 | ... 46 more\rjvm 1 | Caused by: java.net.BindException: Address already in use: JVM_Bind\rjvm 1 | at java.net.DualStackPlainSocketImpl.bind0(Native Method)\rjvm 1 | at java.net.DualStackPlainSocketImpl.socketBind(DualStackPlainSocketImpl.java:106)\rjvm 1 | at java.net.AbstractPlainSocketImpl.bind(AbstractPlainSocketImpl.java:387)\rjvm 1 | at java.net.PlainSocketImpl.bind(PlainSocketImpl.java:190)\rjvm 1 | at java.net.ServerSocket.bind(ServerSocket.java:375)\rjvm 1 | at java.net.ServerSocket.\u0026lt;init\u0026gt;(ServerSocket.java:237)\rjvm 1 | at javax.net.DefaultServerSocketFactory.createServerSocket(ServerSocketFactory.java:231)\rjvm 1 | at org.apache.activemq.transport.tcp.TcpTransportServer.bind(TcpTransportServer.java:143)\rjvm 1 | ... 52 more\rjvm 1 | INFO | Apache ActiveMQ 5.16.3 (localhost, ID:CZBK-20210302VL-10257-1646035577620-0:1) is shutting down\rjvm 1 | INFO | socketQueue interrupted - stopping\rjvm 1 | INFO | Connector openwire stopped\rjvm 1 | INFO | Could not accept connection during shutdown : null (null)\rjvm 1 | INFO | Connector amqp stopped\rjvm 1 | INFO | Connector stomp stopped\rjvm 1 | INFO | Connector mqtt stopped\rjvm 1 | INFO | Connector ws stopped\rjvm 1 | INFO | PListStore:[D:\\soft\\activemq\\bin\\win64\\..\\..\\data\\localhost\\tmp_storage] stopped\rjvm 1 | INFO | Stopping async queue tasks\rjvm 1 | INFO | Stopping async topic tasks\rjvm 1 | INFO | Stopped KahaDB\rjvm 1 | INFO | Apache ActiveMQ 5.16.3 (localhost, ID:CZBK-20210302VL-10257-1646035577620-0:1) uptime 0.426 seconds\rjvm 1 | INFO | Apache ActiveMQ 5.16.3 (localhost, ID:CZBK-20210302VL-10257-1646035577620-0:1) is shutdown\rjvm 1 | INFO | Closing org.apache.activemq.xbean.XBeanBrokerFactory$1@2c9392f5: startup date [Mon Feb 28 16:06:16 CST 2022]; root of context hierarchy\rjvm 1 | WARN | Exception encountered during context initialization - cancelling refresh attempt: org.springframework.beans.factory.BeanCreationException: Error creating bean with name \u0026#39;org.apache.activemq.xbean.XBeanBrokerService#0\u0026#39; defined in class path resource [activemq.xml]: Invocation of init method failed; nested exception is java.io.IOException: Transport Connector could not be registered in JMX: java.io.IOException: Failed to bind to server socket: amqp://0.0.0.0:5672?maximumConnections=1000\u0026amp;wireFormat.maxFrameSize=104857600 due to: java.net.BindException: Address already in use: JVM_Bind\rjvm 1 | ERROR: java.lang.RuntimeException: Failed to execute start task. Reason: java.lang.IllegalStateException: BeanFactory not initialized or already closed - call \u0026#39;refresh\u0026#39; before accessing beans via the ApplicationContext\rjvm 1 | java.lang.RuntimeException: Failed to execute start task. Reason: java.lang.IllegalStateException: BeanFactory not initialized or already closed - call \u0026#39;refresh\u0026#39; before accessing beans via the ApplicationContext\rjvm 1 | at org.apache.activemq.console.command.StartCommand.runTask(StartCommand.java:91)\rjvm 1 | at org.apache.activemq.console.command.AbstractCommand.execute(AbstractCommand.java:63)\rjvm 1 | at org.apache.activemq.console.command.ShellCommand.runTask(ShellCommand.java:154)\rjvm 1 | at org.apache.activemq.console.command.AbstractCommand.execute(AbstractCommand.java:63)\rjvm 1 | at org.apache.activemq.console.command.ShellCommand.main(ShellCommand.java:104)\rjvm 1 | at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)\rjvm 1 | at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)\rjvm 1 | at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)\rjvm 1 | at java.lang.reflect.Method.invoke(Method.java:498)\rjvm 1 | at org.apache.activemq.console.Main.runTaskClass(Main.java:262)\rjvm 1 | at org.apache.activemq.console.Main.main(Main.java:115)\rjvm 1 | at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)\rjvm 1 | at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)\rjvm 1 | at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)\rjvm 1 | at java.lang.reflect.Method.invoke(Method.java:498)\rjvm 1 | at org.tanukisoftware.wrapper.WrapperSimpleApp.run(WrapperSimpleApp.java:240)\rjvm 1 | at java.lang.Thread.run(Thread.java:748)\rjvm 1 | Caused by: java.lang.IllegalStateException: BeanFactory not initialized or already closed - call \u0026#39;refresh\u0026#39; before accessing beans via the ApplicationContext\rjvm 1 | at org.springframework.context.support.AbstractRefreshableApplicationContext.getBeanFactory(AbstractRefreshableApplicationContext.java:164)\rjvm 1 | at org.springframework.context.support.AbstractApplicationContext.destroyBeans(AbstractApplicationContext.java:1034)\rjvm 1 | at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:555)\rjvm 1 | at org.apache.xbean.spring.context.ResourceXmlApplicationContext.\u0026lt;init\u0026gt;(ResourceXmlApplicationContext.java:64)\rjvm 1 | at org.apache.xbean.spring.context.ResourceXmlApplicationContext.\u0026lt;init\u0026gt;(ResourceXmlApplicationContext.java:52)\rjvm 1 | at org.apache.activemq.xbean.XBeanBrokerFactory$1.\u0026lt;init\u0026gt;(XBeanBrokerFactory.java:104)\rjvm 1 | at org.apache.activemq.xbean.XBeanBrokerFactory.createApplicationContext(XBeanBrokerFactory.java:104)\rjvm 1 | at org.apache.activemq.xbean.XBeanBrokerFactory.createBroker(XBeanBrokerFactory.java:67)\rjvm 1 | at org.apache.activemq.broker.BrokerFactory.createBroker(BrokerFactory.java:71)\rjvm 1 | at org.apache.activemq.broker.BrokerFactory.createBroker(BrokerFactory.java:54)\rjvm 1 | at org.apache.activemq.console.command.StartCommand.runTask(StartCommand.java:87)\rjvm 1 | ... 16 more\rjvm 1 | ERROR: java.lang.IllegalStateException: BeanFactory not initialized or already closed - call \u0026#39;refresh\u0026#39; before accessing beans via the ApplicationContext\rjvm 1 | java.lang.IllegalStateException: BeanFactory not initialized or already closed - call \u0026#39;refresh\u0026#39; before accessing beans via the ApplicationContext\rjvm 1 | at org.springframework.context.support.AbstractRefreshableApplicationContext.getBeanFactory(AbstractRefreshableApplicationContext.java:164)\rjvm 1 | at org.springframework.context.support.AbstractApplicationContext.destroyBeans(AbstractApplicationContext.java:1034)\rjvm 1 | at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:555)\rjvm 1 | at org.apache.xbean.spring.context.ResourceXmlApplicationContext.\u0026lt;init\u0026gt;(ResourceXmlApplicationContext.java:64)\rjvm 1 | at org.apache.xbean.spring.context.ResourceXmlApplicationContext.\u0026lt;init\u0026gt;(ResourceXmlApplicationContext.java:52)\rjvm 1 | at org.apache.activemq.xbean.XBeanBrokerFactory$1.\u0026lt;init\u0026gt;(XBeanBrokerFactory.java:104)\rjvm 1 | at org.apache.activemq.xbean.XBeanBrokerFactory.createApplicationContext(XBeanBrokerFactory.java:104)\rjvm 1 | at org.apache.activemq.xbean.XBeanBrokerFactory.createBroker(XBeanBrokerFactory.java:67)\rjvm 1 | at org.apache.activemq.broker.BrokerFactory.createBroker(BrokerFactory.java:71)\rjvm 1 | at org.apache.activemq.broker.BrokerFactory.createBroker(BrokerFactory.java:54)\rjvm 1 | at org.apache.activemq.console.command.StartCommand.runTask(StartCommand.java:87)\rjvm 1 | at org.apache.activemq.console.command.AbstractCommand.execute(AbstractCommand.java:63)\rjvm 1 | at org.apache.activemq.console.command.ShellCommand.runTask(ShellCommand.java:154)\rjvm 1 | at org.apache.activemq.console.command.AbstractCommand.execute(AbstractCommand.java:63)\rjvm 1 | at org.apache.activemq.console.command.ShellCommand.main(ShellCommand.java:104)\rjvm 1 | at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)\rjvm 1 | at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)\rjvm 1 | at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)\rjvm 1 | at java.lang.reflect.Method.invoke(Method.java:498)\rjvm 1 | at org.apache.activemq.console.Main.runTaskClass(Main.java:262)\rjvm 1 | at org.apache.activemq.console.Main.main(Main.java:115)\rjvm 1 | at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)\rjvm 1 | at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)\rjvm 1 | at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)\rjvm 1 | at java.lang.reflect.Method.invoke(Method.java:498)\rjvm 1 | at org.tanukisoftware.wrapper.WrapperSimpleApp.run(WrapperSimpleApp.java:240)\rjvm 1 | at java.lang.Thread.run(Thread.java:748)\rwrapper | \u0026lt;-- Wrapper Stopped\r请按任意键继续. . . 整合\r​\t做了这么多springboot整合第三方技术，已经摸到门路了，加坐标，做配置，调接口，直接开工\n步骤①：导入springboot整合ActiveMQ的starter\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-activemq\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; 步骤②：配置ActiveMQ的服务器地址\nspring: activemq: broker-url: tcp://localhost:61616 步骤③：使用JmsMessagingTemplate操作ActiveMQ\n@Service public class MessageServiceActivemqImpl implements MessageService { @Autowired private JmsMessagingTemplate messagingTemplate; @Override public void sendMessage(String id) { System.out.println(\u0026#34;待发送短信的订单已纳入处理队列，id：\u0026#34;+id); messagingTemplate.convertAndSend(\u0026#34;order.queue.id\u0026#34;,id); } @Override public String doMessage() { String id = messagingTemplate.receiveAndConvert(\u0026#34;order.queue.id\u0026#34;,String.class); System.out.println(\u0026#34;已完成短信发送业务，id：\u0026#34;+id); return id; } } ​\t发送消息需要先将消息的类型转换成字符串，然后再发送，所以是convertAndSend，定义消息发送的位置，和具体的消息内容，此处使用id作为消息内容。\n​\t接收消息需要先将消息接收到，然后再转换成指定的数据类型，所以是receiveAndConvert，接收消息除了提供读取的位置，还要给出转换后的数据的具体类型。\n步骤④：使用消息监听器在服务器启动后，监听指定位置，当消息出现后，立即消费消息\n@Component public class MessageListener { @JmsListener(destination = \u0026#34;order.queue.id\u0026#34;) @SendTo(\u0026#34;order.other.queue.id\u0026#34;) public String receive(String id){ System.out.println(\u0026#34;已完成短信发送业务，id：\u0026#34;+id); return \u0026#34;new:\u0026#34;+id; } } ​\t使用注解@JmsListener定义当前方法监听ActiveMQ中指定名称的消息队列。\n​\t如果当前消息队列处理完还需要继续向下传递当前消息到另一个队列中使用注解@SendTo即可，这样即可构造连续执行的顺序消息队列。\n步骤⑤：切换消息模型由点对点模型到发布订阅模型，修改jms配置即可\nspring: activemq: broker-url: tcp://localhost:61616 jms: pub-sub-domain: true ​\tpub-sub-domain默认值为false，即点对点模型，修改为true后就是发布订阅模型。\n总结\nspringboot整合ActiveMQ提供了JmsMessagingTemplate对象作为客户端操作消息队列 操作ActiveMQ需要配置ActiveMQ服务器地址，默认端口61616 企业开发时通常使用监听器来处理消息队列中的消息，设置监听器使用注解@JmsListener 配置jms的pub-sub-domain属性可以在点对点模型和发布订阅模型间切换消息模型 SpringBoot整合RabbitMQ\r​\tRabbitMQ是MQ产品中的目前较为流行的产品之一，它遵从AMQP协议。RabbitMQ的底层实现语言使用的是Erlang，所以安装RabbitMQ需要先安装Erlang。\nErlang安装\n​\twindows版安装包下载地址：https://www.erlang.org/downloads\n​\t下载完毕后得到exe安装文件，一键傻瓜式安装，安装完毕需要重启，需要重启，需要重启。\n​\t安装的过程中可能会出现依赖Windows组件的提示，根据提示下载安装即可，都是自动执行的，如下：\n​\tErlang安装后需要配置环境变量，否则RabbitMQ将无法找到安装的Erlang。需要配置项如下，作用等同JDK配置环境变量的作用。\nERLANG_HOME PATH 安装\r​\twindows版安装包下载地址：https://rabbitmq.com/install-windows.html\n​\t下载完毕后得到exe安装文件，一键傻瓜式安装，安装完毕后会得到如下文件\n启动服务器\nrabbitmq-service.bat start\t# 启动服务\rrabbitmq-service.bat stop\t# 停止服务\rrabbitmqctl status\t# 查看服务状态 ​\t运行sbin目录下的rabbitmq-service.bat命令即可，start参数表示启动，stop参数表示退出，默认对外服务端口5672。\n​\t注意：启动rabbitmq的过程实际上是开启rabbitmq对应的系统服务，需要管理员权限方可执行。\n​\t说明：有没有感觉5672的服务端口很熟悉？activemq与rabbitmq有一个端口冲突问题，学习阶段无论操作哪一个？请确保另一个处于关闭状态。\n​\t说明：不喜欢命令行的小伙伴可以使用任务管理器中的服务页，找到RabbitMQ服务，使用鼠标右键菜单控制服务的启停。\n访问web管理服务\n​\tRabbitMQ也提供有web控制台服务，但是此功能是一个插件，需要先启用才可以使用。\nrabbitmq-plugins.bat list\t# 查看当前所有插件的运行状态\rrabbitmq-plugins.bat enable rabbitmq_management\t# 启动rabbitmq_management插件 ​\t启动插件后可以在插件运行状态中查看是否运行，运行后通过浏览器即可打开服务后台管理界面\nhttp://localhost:15672 ​\tweb管理服务默认端口15672，访问后可以打开RabbitMQ的管理界面，如下：\n​\t首先输入访问用户名和密码，初始化用户名和密码相同，均为：guest，成功登录后进入管理后台界面，如下：\n整合(direct模型)\r​\tRabbitMQ满足AMQP协议，因此不同的消息模型对应的制作不同，先使用最简单的direct模型开发。\n步骤①：导入springboot整合amqp的starter，amqp协议默认实现为rabbitmq方案\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-amqp\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; 步骤②：配置RabbitMQ的服务器地址\nspring: rabbitmq: host: localhost port: 5672 步骤③：初始化直连模式系统设置\n​\t由于RabbitMQ不同模型要使用不同的交换机，因此需要先初始化RabbitMQ相关的对象，例如队列，交换机等\n@Configuration public class RabbitConfigDirect { @Bean public Queue directQueue(){ return new Queue(\u0026#34;direct_queue\u0026#34;); } @Bean public Queue directQueue2(){ return new Queue(\u0026#34;direct_queue2\u0026#34;); } @Bean public DirectExchange directExchange(){ return new DirectExchange(\u0026#34;directExchange\u0026#34;); } @Bean public Binding bindingDirect(){ return BindingBuilder.bind(directQueue()).to(directExchange()).with(\u0026#34;direct\u0026#34;); } @Bean public Binding bindingDirect2(){ return BindingBuilder.bind(directQueue2()).to(directExchange()).with(\u0026#34;direct2\u0026#34;); } } ​\t队列Queue与直连交换机DirectExchange创建后，还需要绑定他们之间的关系Binding，这样就可以通过交换机操作对应队列。\n步骤④：使用AmqpTemplate操作RabbitMQ\n@Service public class MessageServiceRabbitmqDirectImpl implements MessageService { @Autowired private AmqpTemplate amqpTemplate; @Override public void sendMessage(String id) { System.out.println(\u0026#34;待发送短信的订单已纳入处理队列（rabbitmq direct），id：\u0026#34;+id); amqpTemplate.convertAndSend(\u0026#34;directExchange\u0026#34;,\u0026#34;direct\u0026#34;,id); } } ​\tamqp协议中的操作API接口名称看上去和jms规范的操作API接口很相似，但是传递参数差异很大。\n步骤⑤：使用消息监听器在服务器启动后，监听指定位置，当消息出现后，立即消费消息\n@Component public class MessageListener { @RabbitListener(queues = \u0026#34;direct_queue\u0026#34;) public void receive(String id){ System.out.println(\u0026#34;已完成短信发送业务(rabbitmq direct)，id：\u0026#34;+id); } } ​\t使用注解@RabbitListener定义当前方法监听RabbitMQ中指定名称的消息队列。\n整合(topic模型)\r步骤①：同上\n步骤②：同上\n步骤③：初始化主题模式系统设置\n@Configuration public class RabbitConfigTopic { @Bean public Queue topicQueue(){ return new Queue(\u0026#34;topic_queue\u0026#34;); } @Bean public Queue topicQueue2(){ return new Queue(\u0026#34;topic_queue2\u0026#34;); } @Bean public TopicExchange topicExchange(){ return new TopicExchange(\u0026#34;topicExchange\u0026#34;); } @Bean public Binding bindingTopic(){ return BindingBuilder.bind(topicQueue()).to(topicExchange()).with(\u0026#34;topic.*.id\u0026#34;); } @Bean public Binding bindingTopic2(){ return BindingBuilder.bind(topicQueue2()).to(topicExchange()).with(\u0026#34;topic.orders.*\u0026#34;); } } ​\t主题模式支持routingKey匹配模式，*表示匹配一个单词，#表示匹配任意内容，这样就可以通过主题交换机将消息分发到不同的队列中，详细内容请参看RabbitMQ系列课程。\n匹配键 topic.*.* topic.# topic.order.id true true order.topic.id false false topic.sm.order.id false true topic.sm.id false true topic.id.order true true topic.id false true topic.order false true 步骤④：使用AmqpTemplate操作RabbitMQ\n@Service public class MessageServiceRabbitmqTopicImpl implements MessageService { @Autowired private AmqpTemplate amqpTemplate; @Override public void sendMessage(String id) { System.out.println(\u0026#34;待发送短信的订单已纳入处理队列（rabbitmq topic），id：\u0026#34;+id); amqpTemplate.convertAndSend(\u0026#34;topicExchange\u0026#34;,\u0026#34;topic.orders.id\u0026#34;,id); } } ​\t发送消息后，根据当前提供的routingKey与绑定交换机时设定的routingKey进行匹配，规则匹配成功消息才会进入到对应的队列中。\n步骤⑤：使用消息监听器在服务器启动后，监听指定队列\n@Component public class MessageListener { @RabbitListener(queues = \u0026#34;topic_queue\u0026#34;) public void receive(String id){ System.out.println(\u0026#34;已完成短信发送业务(rabbitmq topic 1)，id：\u0026#34;+id); } @RabbitListener(queues = \u0026#34;topic_queue2\u0026#34;) public void receive2(String id){ System.out.println(\u0026#34;已完成短信发送业务(rabbitmq topic 22222222)，id：\u0026#34;+id); } } ​\t使用注解@RabbitListener定义当前方法监听RabbitMQ中指定名称的消息队列。\n总结\nspringboot整合RabbitMQ提供了AmqpTemplate对象作为客户端操作消息队列 操作ActiveMQ需要配置ActiveMQ服务器地址，默认端口5672 企业开发时通常使用监听器来处理消息队列中的消息，设置监听器使用注解@RabbitListener RabbitMQ有5种消息模型，使用的队列相同，但是交换机不同。交换机不同，对应的消息进入的策略也不同 SpringBoot整合RocketMQ\r​\tRocketMQ由阿里研发，后捐赠给apache基金会，目前是apache基金会顶级项目之一，也是目前市面上的MQ产品中较为流行的产品之一，它遵从AMQP协议。\n安装\r​\twindows版安装包下载地址：https://rocketmq.apache.org/\n​\t下载完毕后得到zip压缩文件，解压缩即可使用，解压后得到如下文件\n​\tRocketMQ安装后需要配置环境变量，具体如下：\nROCKETMQ_HOME PATH NAMESRV_ADDR （建议）： 127.0.0.1:9876 ​\t关于NAMESRV_ADDR对于初学者来说建议配置此项，也可以通过命令设置对应值，操作略显繁琐，建议配置。系统学习RocketMQ知识后即可灵活控制该项。\nRocketMQ工作模式\n​\t在RocketMQ中，处理业务的服务器称为broker，生产者与消费者不是直接与broker联系的，而是通过命名服务器进行通信。broker启动后会通知命名服务器自己已经上线，这样命名服务器中就保存有所有的broker信息。当生产者与消费者需要连接broker时，通过命名服务器找到对应的处理业务的broker，因此命名服务器在整套结构中起到一个信息中心的作用。并且broker启动前必须保障命名服务器先启动。\n启动服务器\nmqnamesrv\t# 启动命名服务器\rmqbroker\t# 启动broker ​\t运行bin目录下的mqnamesrv命令即可启动命名服务器，默认对外服务端口9876。\n​\t运行bin目录下的mqbroker命令即可启动broker服务器，如果环境变量中没有设置NAMESRV_ADDR则需要在运行mqbroker指令前通过set指令设置NAMESRV_ADDR的值，并且每次开启均需要设置此项。\n测试服务器启动状态\n​\tRocketMQ提供有一套测试服务器功能的测试程序，运行bin目录下的tools命令即可使用。\ntools org.apache.rocketmq.example.quickstart.Producer\t# 生产消息\rtools org.apache.rocketmq.example.quickstart.Consumer\t# 消费消息 整合（异步消息）\r步骤①：导入springboot整合RocketMQ的starter，此坐标不由springboot维护版本\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.apache.rocketmq\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;rocketmq-spring-boot-starter\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.2.1\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; 步骤②：配置RocketMQ的服务器地址\nrocketmq: name-server: localhost:9876 producer: group: group_rocketmq ​\t设置默认的生产者消费者所属组group。\n步骤③：使用RocketMQTemplate操作RocketMQ\n@Service public class MessageServiceRocketmqImpl implements MessageService { @Autowired private RocketMQTemplate rocketMQTemplate; @Override public void sendMessage(String id) { System.out.println(\u0026#34;待发送短信的订单已纳入处理队列（rocketmq），id：\u0026#34;+id); SendCallback callback = new SendCallback() { @Override public void onSuccess(SendResult sendResult) { System.out.println(\u0026#34;消息发送成功\u0026#34;); } @Override public void onException(Throwable e) { System.out.println(\u0026#34;消息发送失败！！！！！\u0026#34;); } }; rocketMQTemplate.asyncSend(\u0026#34;order_id\u0026#34;,id,callback); } } ​\t使用asyncSend方法发送异步消息。\n步骤④：使用消息监听器在服务器启动后，监听指定位置，当消息出现后，立即消费消息\n@Component @RocketMQMessageListener(topic = \u0026#34;order_id\u0026#34;,consumerGroup = \u0026#34;group_rocketmq\u0026#34;) public class MessageListener implements RocketMQListener\u0026lt;String\u0026gt; { @Override public void onMessage(String id) { System.out.println(\u0026#34;已完成短信发送业务(rocketmq)，id：\u0026#34;+id); } } ​\tRocketMQ的监听器必须按照标准格式开发，实现RocketMQListener接口，泛型为消息类型。\n​\t使用注解@RocketMQMessageListener定义当前类监听RabbitMQ中指定组、指定名称的消息队列。\n总结\nspringboot整合RocketMQ使用RocketMQTemplate对象作为客户端操作消息队列 操作RocketMQ需要配置RocketMQ服务器地址，默认端口9876 企业开发时通常使用监听器来处理消息队列中的消息，设置监听器使用注解@RocketMQMessageListener SpringBoot整合Kafka\r安装\r​\twindows版安装包下载地址：https://kafka.apache.org/downloads\n​\t下载完毕后得到tgz压缩文件，使用解压缩软件解压缩即可使用，解压后得到如下文件\n​\t建议使用windows版2.8.1版本。\n启动服务器\n​\tkafka服务器的功能相当于RocketMQ中的broker，kafka运行还需要一个类似于命名服务器的服务。在kafka安装目录中自带一个类似于命名服务器的工具，叫做zookeeper，它的作用是注册中心，相关知识请到对应课程中学习。\nzookeeper-server-start.bat ..\\..\\config\\zookeeper.properties\t# 启动zookeeper\rkafka-server-start.bat ..\\..\\config\\server.properties\t# 启动kafka ​\t运行bin目录下的windows目录下的zookeeper-server-start命令即可启动注册中心，默认对外服务端口2181。\n​\t运行bin目录下的windows目录下的kafka-server-start命令即可启动kafka服务器，默认对外服务端口9092。\n创建主题\n​\t和之前操作其他MQ产品相似，kakfa也是基于主题操作，操作之前需要先初始化topic。\n# 创建topic\rkafka-topics.bat --create --zookeeper localhost:2181 --replication-factor 1 --partitions 1 --topic itheima\r# 查询topic\rkafka-topics.bat --zookeeper 127.0.0.1:2181 --list\t# 删除topic\rkafka-topics.bat --delete --zookeeper localhost:2181 --topic itheima 测试服务器启动状态\n​\tKafka提供有一套测试服务器功能的测试程序，运行bin目录下的windows目录下的命令即可使用。\nkafka-console-producer.bat --broker-list localhost:9092 --topic itheima\t# 测试生产消息\rkafka-console-consumer.bat --bootstrap-server localhost:9092 --topic itheima --from-beginning\t# 测试消息消费 整合\r步骤①：导入springboot整合Kafka的starter，此坐标由springboot维护版本\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.kafka\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-kafka\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; 步骤②：配置Kafka的服务器地址\nspring: kafka: bootstrap-servers: localhost:9092 consumer: group-id: order ​\t设置默认的生产者消费者所属组id。\n步骤③：使用KafkaTemplate操作Kafka\n@Service public class MessageServiceKafkaImpl implements MessageService { @Autowired private KafkaTemplate\u0026lt;String,String\u0026gt; kafkaTemplate; @Override public void sendMessage(String id) { System.out.println(\u0026#34;待发送短信的订单已纳入处理队列（kafka），id：\u0026#34;+id); kafkaTemplate.send(\u0026#34;itheima2022\u0026#34;,id); } } ​\t使用send方法发送消息，需要传入topic名称。\n步骤④：使用消息监听器在服务器启动后，监听指定位置，当消息出现后，立即消费消息\n@Component public class MessageListener { @KafkaListener(topics = \u0026#34;itheima2022\u0026#34;) public void onMessage(ConsumerRecord\u0026lt;String,String\u0026gt; record){ System.out.println(\u0026#34;已完成短信发送业务(kafka)，id：\u0026#34;+record.value()); } } ​\t使用注解@KafkaListener定义当前方法监听Kafka中指定topic的消息，接收到的消息封装在对象ConsumerRecord中，获取数据从ConsumerRecord对象中获取即可。\n总结\nspringboot整合Kafka使用KafkaTemplate对象作为客户端操作消息队列\n操作Kafka需要配置Kafka服务器地址，默认端口9092\n企业开发时通常使用监听器来处理消息队列中的消息，设置监听器使用注解@KafkaListener。接收消息保存在形参ConsumerRecord对象中\nKF-6.监控\r​\t在说监控之前，需要回顾一下软件业的发展史。最早的软件完成一些非常简单的功能，代码不多，错误也少。随着软件功能的逐步完善，软件的功能变得越来越复杂，功能不能得到有效的保障，这个阶段出现了针对软件功能的检测，也就是软件测试。伴随着计算机操作系统的逐步升级，软件的运行状态也变得开始让人捉摸不透，出现了不稳定的状况。伴随着计算机网络的发展，程序也从单机状态切换成基于计算机网络的程序，应用于网络的程序开始出现，由于网络的不稳定性，程序的运行状态让使用者更加堪忧。互联网的出现彻底打破了软件的思维模式，随之而来的互联网软件就更加凸显出应对各种各样复杂的网络情况之下的弱小。计算机软件的运行状况已经成为了软件运行的一个大话题，针对软件的运行状况就出现了全新的思维，建立起了初代的软件运行状态监控。\n​\t什么是监控？就是通过软件的方式展示另一个软件的运行情况，运行的情况则通过各种各样的指标数据反馈给监控人员。例如网络是否顺畅、服务器是否在运行、程序的功能是否能够整百分百运行成功，内存是否够用，等等等等。\n​\t本章要讲解的监控就是对软件的运行情况进行监督，但是springboot程序与非springboot程序的差异还是很大的，为了方便监控软件的开发，springboot提供了一套功能接口，为开发者加速开发过程。\nKF-6-1.监控的意义\r​\t对于现代的互联网程序来说，规模越来越大，功能越来越复杂，还要追求更好的客户体验，因此要监控的信息量也就比较大了。由于现在的互联网程序大部分都是基于微服务的程序，一个程序的运行需要若干个服务来保障，因此第一个要监控的指标就是服务是否正常运行，也就是监控服务状态是否处理宕机状态。一旦发现某个服务宕机了，必须马上给出对应的解决方案，避免整体应用功能受影响。其次，由于互联网程序服务的客户量是巨大的，当客户的请求在短时间内集中达到服务器后，就会出现各种程序运行指标的波动。比如内存占用严重，请求无法及时响应处理等，这就是第二个要监控的重要指标，监控服务运行指标。虽然软件是对外提供用户的访问需求，完成对应功能的，但是后台的运行是否平稳，是否出现了不影响客户使用的功能隐患，这些也是要密切监控的，此时就需要在不停机的情况下，监控系统运行情况，日志是一个不错的手段。如果在众多日志中找到开发者或运维人员所关注的日志信息，简单快速有效的过滤出要看的日志也是监控系统需要考虑的问题，这就是第三个要监控的指标，监控程序运行日志。虽然我们期望程序一直平稳运行，但是由于突发情况的出现，例如服务器被攻击、服务器内存溢出等情况造成了服务器宕机，此时当前服务不能满足使用需要，就要将其重启甚至关闭，如果快速控制服务器的启停也是程序运行过程中不可回避的问题，这就是第四个监控项，管理服务状态。以上这些仅仅是从大的方面来思考监控这个问题，还有很多的细节点，例如上线了一个新功能，定时提醒用户续费，这种功能不是上线后马上就运行的，但是当前功能是否真的启动，如果快速的查询到这个功能已经开启，这也是监控中要解决的问题，等等。看来监控真的是一项非常重要的工作。\n​\t通过上述描述，可以看出监控很重要。那具体的监控要如何开展呢？还要从实际的程序运行角度出发。比如现在有3个服务支撑着一个程序的运行，每个服务都有自己的运行状态。\n​\t此时被监控的信息就要在三个不同的程序中去查询并展示，但是三个服务是服务于一个程序的运行的，如果不能合并到一个平台上展示，监控工作量巨大，而且信息对称性差，要不停的在三个监控端查看数据。如果将业务放大成30个，300个，3000个呢？看来必须有一个单独的平台，将多个被监控的服务对应的监控指标信息汇总在一起，这样更利于监控工作的开展。\n​\t新的程序专门用来监控，新的问题就出现了，是被监控程序主动上报信息还是监控程序主动获取信息？如果监控程序不能主动获取信息，这就意味着监控程序有可能看到的是很久之前被监控程序上报的信息，万一被监控程序宕机了，监控程序就无法区分究竟是好久没法信息了，还是已经下线了。所以监控程序必须具有主动发起请求获取被监控服务信息的能力。\n​\t如果监控程序要监控服务时，主动获取对方的信息。那监控程序如何知道哪些程序被自己监控呢？不可能在监控程序中设置我监控谁，这样互联网上的所有程序岂不是都可以被监控到，这样的话信息安全将无法得到保障。合理的做法只能是在被监控程序启动时上报监控程序，告诉监控程序你可以监控我了。看来需要在被监控程序端做主动上报的操作，这就要求被监控程序中配置对应的监控程序是谁。\n​\t被监控程序可以提供各种各样的指标数据给监控程序看，但是每一个指标都代表着公司的机密信息，并不是所有的指标都可以给任何人看的，乃至运维人员，所以对被监控指标的是否开放出来给监控系统看，也需要做详细的设定。\n​\t以上描述的整个过程就是一个监控系统的基本流程。\n总结\n监控是一个非常重要的工作，是保障程序正常运行的基础手段 监控的过程通过一个监控程序进行，它汇总所有被监控的程序的信息集中统一展示 被监控程序需要主动上报自己被监控，同时要设置哪些指标被监控 思考\n​\t下面就要开始做监控了，新的问题就来了，监控程序怎么做呢？难道要自己写吗？肯定是不现实的，如何进行监控，咱们下节再讲。\nKF-6-2.可视化监控平台\r​\tspringboot抽取了大部分监控系统的常用指标，提出了监控的总思想。然后就有好心的同志根据监控的总思想，制作了一个通用性很强的监控系统，因为是基于springboot监控的核心思想制作的，所以这个程序被命名为Spring Boot Admin。\n​\tSpring Boot Admin，这是一个开源社区项目，用于管理和监控SpringBoot应用程序。这个项目中包含有客户端和服务端两部分，而监控平台指的就是服务端。我们做的程序如果需要被监控，将我们做的程序制作成客户端，然后配置服务端地址后，服务端就可以通过HTTP请求的方式从客户端获取对应的信息，并通过UI界面展示对应信息。\n​\t下面就来开发这套监控程序，先制作服务端，其实服务端可以理解为是一个web程序，收到一些信息后展示这些信息。\n服务端开发\n步骤①：导入springboot admin对应的starter，版本与当前使用的springboot版本保持一致，并将其配置成web工程\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;de.codecentric\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-admin-starter-server\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.5.4\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-web\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; ​\t上述过程可以通过创建项目时使用勾选的形式完成。\n步骤②：在引导类上添加注解@EnableAdminServer，声明当前应用启动后作为SpringBootAdmin的服务器使用\n@SpringBootApplication @EnableAdminServer public class Springboot25AdminServerApplication { public static void main(String[] args) { SpringApplication.run(Springboot25AdminServerApplication.class, args); } } ​\t做到这里，这个服务器就开发好了，启动后就可以访问当前程序了，界面如下。\n​\t由于目前没有启动任何被监控的程序，所以里面什么信息都没有。下面制作一个被监控的客户端程序。\n客户端开发\n​\t客户端程序开发其实和服务端开发思路基本相似，多了一些配置而已。\n步骤①：导入springboot admin对应的starter，版本与当前使用的springboot版本保持一致，并将其配置成web工程\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;de.codecentric\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-admin-starter-client\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.5.4\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-web\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; ​\t上述过程也可以通过创建项目时使用勾选的形式完成，不过一定要小心，端口配置成不一样的，否则会冲突。\n步骤②：设置当前客户端将信息上传到哪个服务器上，通过yml文件配置\nspring: boot: admin: client: url: http://localhost:8080 ​\t做到这里，这个客户端就可以启动了。启动后再次访问服务端程序，界面如下。\n​\t可以看到，当前监控了1个程序，点击进去查看详细信息。\n​\t由于当前没有设置开放哪些信息给监控服务器，所以目前看不到什么有效的信息。下面需要做两组配置就可以看到信息了。\n开放指定信息给服务器看\n允许服务器以HTTP请求的方式获取对应的信息\n配置如下：\nserver: port: 80 spring: boot: admin: client: url: http://localhost:8080 management: endpoint: health: show-details: always endpoints: web: exposure: include: \u0026#34;*\u0026#34; ​\t上述配置对于初学者来说比较容易混淆。简单解释一下，到下一节再做具体的讲解。springbootadmin的客户端默认开放了13组信息给服务器，但是这些信息除了一个之外，其他的信息都不让通过HTTP请求查看。所以你看到的信息基本上就没什么内容了，只能看到一个内容，就是下面的健康信息。\n​\t但是即便如此我们看到健康信息中也没什么内容，原因在于健康信息中有一些信息描述了你当前应用使用了什么技术等信息，如果无脑的对外暴露功能会有安全隐患。通过配置就可以开放所有的健康信息明细查看了。\nmanagement: endpoint: health: show-details: always ​\t健康明细信息如下：\n​\t目前除了健康信息，其他信息都查阅不了。原因在于其他12种信息是默认不提供给服务器通过HTTP请求查阅的，所以需要开启查阅的内容项，使用*表示查阅全部。记得带引号。\nendpoints: web: exposure: include: \u0026#34;*\u0026#34; ​\t配置后再刷新服务器页面，就可以看到所有的信息了。\n​\t以上界面中展示的信息量就非常大了，包含了13组信息，有性能指标监控，加载的bean列表，加载的系统属性，日志的显示控制等等。\n配置多个客户端\n​\t可以通过配置客户端的方式在其他的springboot程序中添加客户端坐标，这样当前服务器就可以监控多个客户端程序了。每个客户端展示不同的监控信息。\n​\t进入监控面板，如果你加载的应用具有功能，在监控面板中可以看到3组信息展示的与之前加载的空工程不一样。\n类加载面板中可以查阅到开发者自定义的类，如左图 ​ 映射中可以查阅到当前应用配置的所有请求 ​ 性能指标中可以查阅当前应用独有的请求路径统计数据 ​ 总结\n开发监控服务端需要导入坐标，然后在引导类上添加注解@EnableAdminServer，并将其配置成web程序即可 开发被监控的客户端需要导入坐标，然后配置服务端服务器地址，并做开放指标的设定即可 在监控平台中可以查阅到各种各样被监控的指标，前提是客户端开放了被监控的指标 思考\n​\t之前说过，服务端要想监控客户端，需要主动的获取到对应信息并展示出来。但是目前我们并没有在客户端开发任何新的功能，但是服务端确可以获取监控信息，谁帮我们做的这些功能呢？咱们下一节再讲。\nKF-6-3.监控原理\r​\t通过查阅监控中的映射指标，可以看到当前系统中可以运行的所有请求路径，其中大部分路径以/actuator开头\n​\t首先这些请求路径不是开发者自己编写的，其次这个路径代表什么含义呢？既然这个路径可以访问，就可以通过浏览器发送该请求看看究竟可以得到什么信息。\n​\t通过发送请求，可以得到一组json信息，如下\n{ \u0026#34;_links\u0026#34;: { \u0026#34;self\u0026#34;: { \u0026#34;href\u0026#34;: \u0026#34;http://localhost:81/actuator\u0026#34;, \u0026#34;templated\u0026#34;: false }, \u0026#34;beans\u0026#34;: { \u0026#34;href\u0026#34;: \u0026#34;http://localhost:81/actuator/beans\u0026#34;, \u0026#34;templated\u0026#34;: false }, \u0026#34;caches-cache\u0026#34;: { \u0026#34;href\u0026#34;: \u0026#34;http://localhost:81/actuator/caches/{cache}\u0026#34;, \u0026#34;templated\u0026#34;: true }, \u0026#34;caches\u0026#34;: { \u0026#34;href\u0026#34;: \u0026#34;http://localhost:81/actuator/caches\u0026#34;, \u0026#34;templated\u0026#34;: false }, \u0026#34;health\u0026#34;: { \u0026#34;href\u0026#34;: \u0026#34;http://localhost:81/actuator/health\u0026#34;, \u0026#34;templated\u0026#34;: false }, \u0026#34;health-path\u0026#34;: { \u0026#34;href\u0026#34;: \u0026#34;http://localhost:81/actuator/health/{*path}\u0026#34;, \u0026#34;templated\u0026#34;: true }, \u0026#34;info\u0026#34;: { \u0026#34;href\u0026#34;: \u0026#34;http://localhost:81/actuator/info\u0026#34;, \u0026#34;templated\u0026#34;: false }, \u0026#34;conditions\u0026#34;: { \u0026#34;href\u0026#34;: \u0026#34;http://localhost:81/actuator/conditions\u0026#34;, \u0026#34;templated\u0026#34;: false }, \u0026#34;shutdown\u0026#34;: { \u0026#34;href\u0026#34;: \u0026#34;http://localhost:81/actuator/shutdown\u0026#34;, \u0026#34;templated\u0026#34;: false }, \u0026#34;configprops\u0026#34;: { \u0026#34;href\u0026#34;: \u0026#34;http://localhost:81/actuator/configprops\u0026#34;, \u0026#34;templated\u0026#34;: false }, \u0026#34;configprops-prefix\u0026#34;: { \u0026#34;href\u0026#34;: \u0026#34;http://localhost:81/actuator/configprops/{prefix}\u0026#34;, \u0026#34;templated\u0026#34;: true }, \u0026#34;env\u0026#34;: { \u0026#34;href\u0026#34;: \u0026#34;http://localhost:81/actuator/env\u0026#34;, \u0026#34;templated\u0026#34;: false }, \u0026#34;env-toMatch\u0026#34;: { \u0026#34;href\u0026#34;: \u0026#34;http://localhost:81/actuator/env/{toMatch}\u0026#34;, \u0026#34;templated\u0026#34;: true }, \u0026#34;loggers\u0026#34;: { \u0026#34;href\u0026#34;: \u0026#34;http://localhost:81/actuator/loggers\u0026#34;, \u0026#34;templated\u0026#34;: false }, \u0026#34;loggers-name\u0026#34;: { \u0026#34;href\u0026#34;: \u0026#34;http://localhost:81/actuator/loggers/{name}\u0026#34;, \u0026#34;templated\u0026#34;: true }, \u0026#34;heapdump\u0026#34;: { \u0026#34;href\u0026#34;: \u0026#34;http://localhost:81/actuator/heapdump\u0026#34;, \u0026#34;templated\u0026#34;: false }, \u0026#34;threaddump\u0026#34;: { \u0026#34;href\u0026#34;: \u0026#34;http://localhost:81/actuator/threaddump\u0026#34;, \u0026#34;templated\u0026#34;: false }, \u0026#34;metrics-requiredMetricName\u0026#34;: { \u0026#34;href\u0026#34;: \u0026#34;http://localhost:81/actuator/metrics/{requiredMetricName}\u0026#34;, \u0026#34;templated\u0026#34;: true }, \u0026#34;metrics\u0026#34;: { \u0026#34;href\u0026#34;: \u0026#34;http://localhost:81/actuator/metrics\u0026#34;, \u0026#34;templated\u0026#34;: false }, \u0026#34;scheduledtasks\u0026#34;: { \u0026#34;href\u0026#34;: \u0026#34;http://localhost:81/actuator/scheduledtasks\u0026#34;, \u0026#34;templated\u0026#34;: false }, \u0026#34;mappings\u0026#34;: { \u0026#34;href\u0026#34;: \u0026#34;http://localhost:81/actuator/mappings\u0026#34;, \u0026#34;templated\u0026#34;: false } } } ​\t其中每一组数据都有一个请求路径，而在这里请求路径中有之前看到过的health，发送此请求又得到了一组信息\n{ \u0026#34;status\u0026#34;: \u0026#34;UP\u0026#34;, \u0026#34;components\u0026#34;: { \u0026#34;diskSpace\u0026#34;: { \u0026#34;status\u0026#34;: \u0026#34;UP\u0026#34;, \u0026#34;details\u0026#34;: { \u0026#34;total\u0026#34;: 297042808832, \u0026#34;free\u0026#34;: 72284409856, \u0026#34;threshold\u0026#34;: 10485760, \u0026#34;exists\u0026#34;: true } }, \u0026#34;ping\u0026#34;: { \u0026#34;status\u0026#34;: \u0026#34;UP\u0026#34; } } } ​\t当前信息与监控面板中的数据存在着对应关系\n​\t原来监控中显示的信息实际上是通过发送请求后得到json数据，然后展示出来。按照上述操作，可以发送更多的以/actuator开头的链接地址，获取更多的数据，这些数据汇总到一起组成了监控平台显示的所有数据。\n​\t到这里我们得到了一个核心信息，监控平台中显示的信息实际上是通过对被监控的应用发送请求得到的。那这些请求谁开发的呢？打开被监控应用的pom文件，其中导入了springboot admin的对应的client，在这个资源中导入了一个名称叫做actuator的包。被监控的应用之所以可以对外提供上述请求路径，就是因为添加了这个包。\n​\t这个actuator是什么呢？这就是本节要讲的核心内容，监控的端点。\n​\tActuator，可以称为端点，描述了一组监控信息，SpringBootAdmin提供了多个内置端点，通过访问端点就可以获取对应的监控信息，也可以根据需要自定义端点信息。通过发送请求路劲**/actuator可以访问应用所有端点信息，如果端点中还有明细信息可以发送请求/actuator/端点名称**来获取详细信息。以下列出了所有端点信息说明：\nID 描述 默认启用 auditevents 暴露当前应用程序的审计事件信息。 是 beans 显示应用程序中所有 Spring bean 的完整列表。 是 caches 暴露可用的缓存。 是 conditions 显示在配置和自动配置类上评估的条件以及它们匹配或不匹配的原因。 是 configprops 显示所有 @ConfigurationProperties 的校对清单。 是 env 暴露 Spring ConfigurableEnvironment 中的属性。 是 flyway 显示已应用的 Flyway 数据库迁移。 是 health 显示应用程序健康信息 是 httptrace 显示 HTTP 追踪信息（默认情况下，最后 100 个 HTTP 请求/响应交换）。 是 info 显示应用程序信息。 是 integrationgraph 显示 Spring Integration 图。 是 loggers 显示和修改应用程序中日志记录器的配置。 是 liquibase 显示已应用的 Liquibase 数据库迁移。 是 metrics 显示当前应用程序的指标度量信息。 是 mappings 显示所有 @RequestMapping 路径的整理清单。 是 scheduledtasks 显示应用程序中的调度任务。 是 sessions 允许从 Spring Session 支持的会话存储中检索和删除用户会话。当使用 Spring Session 的响应式 Web 应用程序支持时不可用。 是 shutdown 正常关闭应用程序。 否 threaddump 执行线程 dump。 是 heapdump 返回一个 hprof 堆 dump 文件。 是 jolokia 通过 HTTP 暴露 JMX bean（当 Jolokia 在 classpath 上时，不适用于 WebFlux）。 是 logfile 返回日志文件的内容（如果已设置 logging.file 或 logging.path 属性）。支持使用 HTTP Range 头来检索部分日志文件的内容。 是 prometheus 以可以由 Prometheus 服务器抓取的格式暴露指标。 是 ​\t上述端点每一项代表被监控的指标，如果对外开放则监控平台可以查询到对应的端点信息，如果未开放则无法查询对应的端点信息。通过配置可以设置端点是否对外开放功能。使用enable属性控制端点是否对外开放。其中health端点为默认端点，不能关闭。\nmanagement: endpoint: health:\t# 端点名称 show-details: always info:\t# 端点名称 enabled: true\t# 是否开放 ​\t为了方便开发者快速配置端点，springboot admin设置了13个较为常用的端点作为默认开放的端点，如果需要控制默认开放的端点的开放状态，可以通过配置设置，如下：\nmanagement: endpoints: enabled-by-default: true\t# 是否开启默认端点，默认值true ​\t上述端点开启后，就可以通过端点对应的路径查看对应的信息了。但是此时还不能通过HTTP请求查询此信息，还需要开启通过HTTP请求查询的端点名称，使用“*”可以简化配置成开放所有端点的WEB端HTTP请求权限。\nmanagement: endpoints: web: exposure: include: \u0026#34;*\u0026#34; ​\t整体上来说，对于端点的配置有两组信息，一组是endpoints开头的，对所有端点进行配置，一组是endpoint开头的，对具体端点进行配置。\nmanagement: endpoint:\t# 具体端点的配置 health: show-details: always info: enabled: true endpoints:\t# 全部端点的配置 web: exposure: include: \u0026#34;*\u0026#34; enabled-by-default: true 总结\n被监控客户端通过添加actuator的坐标可以对外提供被访问的端点功能\n端点功能的开放与关闭可以通过配置进行控制\nweb端默认无法获取所有端点信息，通过配置开放端点功能\nKF-6-4.自定义监控指标\r​\t端点描述了被监控的信息，除了系统默认的指标，还可以自行添加显示的指标，下面就通过3种不同的端点的指标自定义方式来学习端点信息的二次开发。\nINFO端点\n​\tinfo端点描述了当前应用的基本信息，可以通过两种形式快速配置info端点的信息\n配置形式\n在yml文件中通过设置info节点的信息就可以快速配置端点信息\ninfo: appName: @project.artifactId@ version: @project.version@ company: 传智教育 author: itheima 配置完毕后，对应信息显示在监控平台上\n也可以通过请求端点信息路径获取对应json信息\n编程形式\n通过配置的形式只能添加固定的数据，如果需要动态数据还可以通过配置bean的方式为info端点添加信息，此信息与配置信息共存\n@Component public class InfoConfig implements InfoContributor { @Override public void contribute(Info.Builder builder) { builder.withDetail(\u0026#34;runTime\u0026#34;,System.currentTimeMillis());\t//添加单个信息 Map infoMap = new HashMap();\tinfoMap.put(\u0026#34;buildTime\u0026#34;,\u0026#34;2006\u0026#34;); builder.withDetails(infoMap);\t//添加一组信息 } } Health端点\n​\thealth端点描述当前应用的运行健康指标，即应用的运行是否成功。通过编程的形式可以扩展指标信息。\n@Component public class HealthConfig extends AbstractHealthIndicator { @Override protected void doHealthCheck(Health.Builder builder) throws Exception { boolean condition = true; if(condition) { builder.status(Status.UP);\t//设置运行状态为启动状态 builder.withDetail(\u0026#34;runTime\u0026#34;, System.currentTimeMillis()); Map infoMap = new HashMap(); infoMap.put(\u0026#34;buildTime\u0026#34;, \u0026#34;2006\u0026#34;); builder.withDetails(infoMap); }else{ builder.status(Status.OUT_OF_SERVICE);\t//设置运行状态为不在服务状态 builder.withDetail(\u0026#34;上线了吗？\u0026#34;,\u0026#34;你做梦\u0026#34;); } } } ​\t当任意一个组件状态不为UP时，整体应用对外服务状态为非UP状态。\nMetrics端点\n​\tmetrics端点描述了性能指标，除了系统自带的监控性能指标，还可以自定义性能指标。\n@Service public class BookServiceImpl extends ServiceImpl\u0026lt;BookDao, Book\u0026gt; implements IBookService { @Autowired private BookDao bookDao; private Counter counter; public BookServiceImpl(MeterRegistry meterRegistry){ counter = meterRegistry.counter(\u0026#34;用户付费操作次数：\u0026#34;); } @Override public boolean delete(Integer id) { //每次执行删除业务等同于执行了付费业务 counter.increment(); return bookDao.deleteById(id) \u0026gt; 0; } } ​\t在性能指标中就出现了自定义的性能指标监控项\n自定义端点\n​\t可以根据业务需要自定义端点，方便业务监控\n@Component @Endpoint(id=\u0026#34;pay\u0026#34;,enableByDefault = true) public class PayEndpoint { @ReadOperation public Object getPay(){ Map payMap = new HashMap(); payMap.put(\u0026#34;level 1\u0026#34;,\u0026#34;300\u0026#34;); payMap.put(\u0026#34;level 2\u0026#34;,\u0026#34;291\u0026#34;); payMap.put(\u0026#34;level 3\u0026#34;,\u0026#34;666\u0026#34;); return payMap; } } ​\t由于此端点数据spirng boot admin无法预知该如何展示，所以通过界面无法看到此数据，通过HTTP请求路径可以获取到当前端点的信息，但是需要先开启当前端点对外功能，或者设置当前端点为默认开发的端点。\n总结\n端点的指标可以自定义，但是每种不同的指标根据其功能不同，自定义方式不同 info端点通过配置和编程的方式都可以添加端点指标 health端点通过编程的方式添加端点指标，需要注意要为对应指标添加启动状态的逻辑设定 metrics指标通过在业务中添加监控操作设置指标 可以自定义端点添加更多的指标 开发实用篇完结\r​\t开发实用篇到这里就暂时完结了，在开发实用篇中我们讲解了大量的第三方技术的整合方案，选择的方案都是市面上比较流行的常用方案，还有一些国内流行度较低的方案目前还没讲，留到番外篇中慢慢讲吧。\n​\t整体开发实用篇中讲解的内容可以分为两大类知识：实用性知识与经验性知识。\n​\t实用性知识就是新知识了，springboot整合各种技术，每种技术整合中都有一些特殊操作，整体来说其实就是三句话。加坐标做配置调接口。经验性知识是对前面两篇中出现的一些知识的补充，在学习基础篇时如果将精力放在这些东西上就有点学偏了，容易钻牛角尖，放到实用开发篇中结合实际开发说一些不常见的但是对系统功能又危害的操作解决方案，提升理解。\n​\t开发实用篇做到这里就告一段落，下面就要着手准备原理篇了。市面上很多课程原理篇讲的过于高深莫测，在新手还没明白123的时候就开始讲微积分了，着实让人看了着急。至于原理篇我讲成什么样子？一起期待吧。\nSpringBoot原理篇\r​\t在学习前面三篇的时候，好多小伙伴一直在B站评论区嚷嚷着期待原理篇，今天可以正式的宣布了，他来了他来了他脚踏祥云进来了（此处请自行脑补BGM）。\n​\t其实从本人的角度出发，看了这么多学习java的小伙伴的学习过程，个人观点，不建议小伙伴过早的去研究技术的原理。原因有二：一，先应用熟练，培养技术应用的条件反射，然后再学原理。大把的学习者天天还纠结于这里少写一个这，那里少写一个那，程序都跑不下去，要啥原理，要啥自行车。这里要说一句啊，懂不懂啥意思那不叫原理，原理是抽象到顶层设计层面的东西。知道为什么写这句话，知道错误的原因和懂原理是两码事。二， 原理真不是看源码，源码只能称作原理的落地实现方式，当好的落地实现方式出现后，就会有新旧版本的迭代，底层实现方式也会伴随着更新升级。但是原理不变，只是找到了更好的实现最初目标的路径。一个好的课程，一位好的老师，不会用若干行云里雾里的源代码把学习者带到沟里，然后爬不出来，深陷泥潭。一边沮丧的看着源码，一边舔着老师奉其为大神，这就叫不干人事。原理就应该使用最通俗易懂的语言，把设计思想讲出来，至于看源码，只是因为目前的技术原创人员只想到了当前这种最笨的设计方案，还没有更好的。比如spirng程序，写起来很费劲，springboot出来以后就简单轻松了很多，实现方案变了，原理不变。但凡你想通过下面的课程学习去读懂若干行代码，然后特别装逼的告诉自己，我懂原理了。我只能告诉你，你选了一条成本最高的路线，看源码仅仅是验证原理，源码仅对应程序流程，不对应原理。原理是思想级的，不是代码级的，原理是原本的道理。\n​\tspringboot技术本身就是为了加速spring程序的开发的，可以大胆的说，springboot技术没有自己的原理层面的设计，仅仅是实现方案进行了改进。将springboot定位成工具，你就不会去想方设法的学习其原理了。就像是将木头分割成若干份，我们可以用斧子，用锯子，用刀，用火烧或者一脚踹断它，这些都是方式方法，而究其本质底层原理是植物纤维的组织方式，研究完这个，你再看前述的各种工具，都是基于这个原理在说如何变更破坏这种植物纤维的方式。所以不要一张嘴说了若干种技术，然后告诉自己，这就是spirngboot的原理。没有的事，springboot作为一款工具，压根就没有原理。我们下面要学习的其实就是spirngboot程序的工作流程。\n​\t下面就开始学习原理篇，因为没有想出来特别好的名字，所以还是先称作原理篇吧。原理篇中包含如下内容：\n自动配置工作流程 自定义starter开发 springboot程序启动流程 ​\t下面开启第一部分自动配置工作流程的学习\nYL-1.自动配置工作流程\r​\t自动配置是springboot技术非常好用的核心因素，前面学习了这么多种技术的整合，每一个都离不开自动配置。不过在学习自动配置的时候，需要你对spring容器如何进行bean管理的过程非常熟悉才行，所以这里需要先复习一下有关spring技术中bean加载相关的知识。方式方法很多，逐一快速复习一下，查漏补缺。不过这里需要声明一点，这里列出的bean的加载方式仅仅应用于后面课程的学习，并不是所有的spring加载bean的方式。跟着我的步伐一种一种的复习，他们这些方案之间有千丝万缕的关系，顺着看完，你就懂自动配置是怎么回事了。\nYL-1-1.bean的加载方式\r​\t关于bean的加载方式，spring提供了各种各样的形式。因为spring管理bean整体上来说就是由spring维护对象的生命周期，所以bean的加载可以从大的方面划分成2种形式。已知类并交给spring管理，和已知类名并交给spring管理。有什么区别？一个给.class，一个给类名字符串。内部其实都一样，都是通过spring的BeanDefinition对象初始化spring的bean。如果前面这句话看起来有障碍，可以去复习一下spring的相关知识。B站中有我尊敬的满一航老师录制的spring高级课程，链接地址如下，欢迎大家捧场，记得一键三连哦。\nhttps://www.bilibili.com/video/BV1P44y1N7QG 方式一：配置文件+\u0026lt;bean/\u0026gt;标签\r​\t最高端的食材往往只需要最简单的烹饪方法，搞错了，再来。最初级的bean的加载方式其实可以直击spring管控bean的核心思想，就是提供类名，然后spring就可以管理了。所以第一种方式就是给出bean的类名，至于内部嘛就是反射机制加载成class，然后，就没有然后了，拿到了class你就可以搞定一切了。如果这句话听不太懂，请这些小盆友转战java基础高级部分复习一下反射相关知识。\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;beans xmlns=\u0026#34;http://www.springframework.org/schema/beans\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd\u0026#34;\u0026gt; \u0026lt;!--xml方式声明自己开发的bean--\u0026gt; \u0026lt;bean id=\u0026#34;cat\u0026#34; class=\u0026#34;Cat\u0026#34;/\u0026gt; \u0026lt;bean class=\u0026#34;Dog\u0026#34;/\u0026gt; \u0026lt;!--xml方式声明第三方开发的bean--\u0026gt; \u0026lt;bean id=\u0026#34;dataSource\u0026#34; class=\u0026#34;com.alibaba.druid.pool.DruidDataSource\u0026#34;/\u0026gt; \u0026lt;bean class=\u0026#34;com.alibaba.druid.pool.DruidDataSource\u0026#34;/\u0026gt; \u0026lt;bean class=\u0026#34;com.alibaba.druid.pool.DruidDataSource\u0026#34;/\u0026gt; \u0026lt;/beans\u0026gt; 方式二：配置文件扫描+注解定义bean\r​\t由于方式一种需要将spring管控的bean全部写在xml文件中，对于程序员来说非常不友好，所以就有了第二种方式。哪一个类要受到spring管控加载成bean，就在这个类的上面加一个注解，还可以顺带起一个bean的名字（id）。这里可以使用的注解有@Component以及三个衍生注解@Service、@Controller、@Repository。\n@Component(\u0026#34;tom\u0026#34;) public class Cat { } @Service public class Mouse { } ​\t当然，由于我们无法在第三方提供的技术源代码中去添加上述4个注解，因此当你需要加载第三方开发的bean的时候可以使用下列方式定义注解式的bean。@Bean定义在一个方法上方，当前方法的返回值就可以交给spring管控，记得这个方法所在的类一定要定义在@Component修饰的类中，有人会说不是@Configuration吗？建议把spring注解开发相关课程学习一下，就不会有这个疑问了。\n@Component public class DbConfig { @Bean public DruidDataSource dataSource(){ DruidDataSource ds = new DruidDataSource(); return ds; } } ​\t上面提供的仅仅是bean的声明，spring并没有感知到这些东西，像极了上课积极回答问题的你，手举的非常高，可惜老师都没有往你的方向看上一眼。想让spring感知到这些积极的小伙伴，必须设置spring去检查这些类，看他们是否贴标签，想当积极分子。可以通过下列xml配置设置spring去检查哪些包，发现定了对应注解，就将对应的类纳入spring管控范围，声明成bean。\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;beans xmlns=\u0026#34;http://www.springframework.org/schema/beans\u0026#34; xmlns:context=\u0026#34;http://www.springframework.org/schema/context\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34; http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd \u0026#34;\u0026gt; \u0026lt;!--指定扫描加载bean的位置--\u0026gt; \u0026lt;context:component-scan base-package=\u0026#34;com.itheima.bean,com.itheima.config\u0026#34;/\u0026gt; \u0026lt;/beans\u0026gt; ​\t方式二声明bean的方式是目前企业中较为常见的bean的声明方式，但是也有缺点。方式一中，通过一个配置文件，你可以查阅当前spring环境中定义了多少个或者说多少种bean，但是方式二没有任何一个地方可以查阅整体信息，只有当程序运行起来才能感知到加载了多少个bean。\n方式三：注解方式声明配置类\r​\t方式二已经完美的简化了bean的声明，以后再也不用写茫茫多的配置信息了。仔细观察xml配置文件，会发现这个文件中只剩了扫描包这句话，于是就有人提出，使用java类替换掉这种固定格式的配置，所以下面这种格式就出现了。严格意义上讲不能算全新的方式，但是由于此种开发形式是企业级开发中的主流形式，所以单独独立出来做成一种方式。嗯……，怎么说呢？方式二和方式三其实差别还是挺大的，番外篇找个时间再聊吧。\n​\t定义一个类并使用@ComponentScan替代原始xml配置中的包扫描这个动作，其实功能基本相同。为什么说基本，还是有差别的。先卖个关子吧，番外篇再聊。\n@ComponentScan({\u0026#34;com.itheima.bean\u0026#34;,\u0026#34;com.itheima.config\u0026#34;}) public class SpringConfig3 { @Bean public DogFactoryBean dog(){ return new DogFactoryBean(); } } 使用FactroyBean接口\r​\t补充一个小知识，spring提供了一个接口FactoryBean，也可以用于声明bean，只不过实现了FactoryBean接口的类造出来的对象不是当前类的对象，而是FactoryBean接口泛型指定类型的对象。如下列，造出来的bean并不是DogFactoryBean，而是Dog。有什么用呢？可以在对象初始化前做一些事情，下例中的注释位置就是让你自己去扩展要做的其他事情的。\npublic class DogFactoryBean implements FactoryBean\u0026lt;Dog\u0026gt; { @Override public Dog getObject() throws Exception { Dog d = new Dog(); //......... return d; } @Override public Class\u0026lt;?\u0026gt; getObjectType() { return Dog.class; } @Override public boolean isSingleton() { return true; } } ​\t有人说，注释中的代码写入Dog的构造方法不就行了吗？干嘛这么费劲转一圈，还写个类，还要实现接口，多麻烦啊。还真不一样，你可以理解为Dog是一个抽象后剥离的特别干净的模型，但是实际使用的时候必须进行一系列的初始化动作。只不过根据情况不同，初始化动作不同而已。如果写入Dog，或许初始化动作A当前并不能满足你的需要，这个时候你就要做一个DogB的方案了。然后，就没有然后了，你就要做两个Dog类。当时使用FactoryBean接口就可以完美解决这个问题。\n​\t通常实现了FactoryBean接口的类使用@Bean的形式进行加载，当然你也可以使用@Component去声明DogFactoryBean，只要被扫描加载到即可，但是这种格式加载总觉得怪怪的，指向性不是很明确。\n@ComponentScan({\u0026#34;com.itheima.bean\u0026#34;,\u0026#34;com.itheima.config\u0026#34;}) public class SpringConfig3 { @Bean public DogFactoryBean dog(){ return new DogFactoryBean(); } } 注解格式导入XML格式配置的bean\r​\t再补充一个小知识，由于早起开发的系统大部分都是采用xml的形式配置bean，现在的企业级开发基本上不用这种模式了。但是如果你特别幸运，需要基于之前的系统进行二次开发，这就尴尬了。新开发的用注解格式，之前开发的是xml格式。这个时候可不是让你选择用哪种模式的，而是两种要同时使用。spring提供了一个注解可以解决这个问题，@ImportResource，在配置类上直接写上要被融合的xml配置文件名即可，算的上一种兼容性解决方案，没啥实际意义。\n@Configuration @ImportResource(\u0026#34;applicationContext1.xml\u0026#34;) public class SpringConfig32 { } proxyBeanMethods属性\r​\t前面的例子中用到了@Configuration这个注解，当我们使用AnnotationConfigApplicationContext加载配置类的时候，配置类可以不添加这个注解。但是这个注解有一个更加强大的功能，它可以保障配置类中使用方法创建的bean的唯一性。为@Configuration注解设置proxyBeanMethods属性值为true即可，由于此属性默认值为true，所以很少看见明确书写的，除非想放弃此功能。\n@Configuration(proxyBeanMethods = true) public class SpringConfig33 { @Bean public Cat cat(){ return new Cat(); } } ​\t下面通过容器再调用上面的cat方法时，得到的就是同一个对象了。注意，必须使用spring容器对象调用此方法才有保持bean唯一性的特性。此特性在很多底层源码中有应用，前面讲MQ时，也应用了此特性，只不过当前没有解释而已。这里算是填个坑吧。\npublic class App33 { public static void main(String[] args) { ApplicationContext ctx = new AnnotationConfigApplicationContext(SpringConfig33.class); String[] names = ctx.getBeanDefinitionNames(); for (String name : names) { System.out.println(name); } System.out.println(\u0026#34;-------------------------\u0026#34;); SpringConfig33 springConfig33 = ctx.getBean(\u0026#34;springConfig33\u0026#34;, SpringConfig33.class); System.out.println(springConfig33.cat()); System.out.println(springConfig33.cat()); System.out.println(springConfig33.cat()); } } 方式四：使用@Import注解注入bean\r​\t使用扫描的方式加载bean是企业级开发中常见的bean的加载方式，但是由于扫描的时候不仅可以加载到你要的东西，还有可能加载到各种各样的乱七八糟的东西，万一没有控制好得不偿失了。\n​\t有人就会奇怪，会有什么问题呢？比如你扫描了com.itheima.service包，后来因为业务需要，又扫描了com.itheima.dao包，你发现com.itheima包下面只有service和dao这两个包，这就简单了，直接扫描com.itheima就行了。但是万万没想到，十天后你加入了一个外部依赖包，里面也有com.itheima包，这下就热闹了，该来的不该来的全来了。\n​\t所以我们需要一种精准制导的加载方式，使用@Import注解就可以解决你的问题。它可以加载所有的一切，只需要在注解的参数中写上加载的类对应的.class即可。有人就会觉得，还要自己手写，多麻烦，不如扫描好用。对呀，但是他可以指定加载啊，好的命名规范配合@ComponentScan可以解决很多问题，但是@Import注解拥有其重要的应用场景。有没有想过假如你要加载的bean没有使用@Component修饰呢？这下就无解了，而@Import就无需考虑这个问题。\n@Import({Dog.class,DbConfig.class}) public class SpringConfig4 { } 使用@Import注解注入配置类\r​\t除了加载bean，还可以使用@Import注解加载配置类。其实本质上是一样的，不解释太多了。\n@Import(DogFactoryBean.class) public class SpringConfig4 { } 方式五：编程形式注册bean\r​\t前面介绍的加载bean的方式都是在容器启动阶段完成bean的加载，下面这种方式就比较特殊了，可以在容器初始化完成后手动加载bean。通过这种方式可以实现编程式控制bean的加载。\npublic class App5 { public static void main(String[] args) { AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(SpringConfig.class); //上下文容器对象已经初始化完毕后，手工加载bean ctx.register(Mouse.class); } } ​\t其实这种方式坑还是挺多的，比如容器中已经有了某种类型的bean，再加载会不会覆盖呢？这都是要思考和关注的问题。新手慎用。\npublic class App5 { public static void main(String[] args) { AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(SpringConfig.class); //上下文容器对象已经初始化完毕后，手工加载bean ctx.registerBean(\u0026#34;tom\u0026#34;, Cat.class,0); ctx.registerBean(\u0026#34;tom\u0026#34;, Cat.class,1); ctx.registerBean(\u0026#34;tom\u0026#34;, Cat.class,2); System.out.println(ctx.getBean(Cat.class)); } } 方式六：导入实现了ImportSelector接口的类\r​\t在方式五种，我们感受了bean的加载可以进行编程化的控制，添加if语句就可以实现bean的加载控制了。但是毕竟是在容器初始化后实现bean的加载控制，那是否可以在容器初始化过程中进行控制呢？答案是必须的。实现ImportSelector接口的类可以设置加载的bean的全路径类名，记得一点，只要能编程就能判定，能判定意味着可以控制程序的运行走向，进而控制一切。\n​\t现在又多了一种控制bean加载的方式，或者说是选择bean的方式。\npublic class MyImportSelector implements ImportSelector { @Override public String[] selectImports(AnnotationMetadata metadata) { //各种条件的判定，判定完毕后，决定是否装载指定的bean boolean flag = metadata.hasAnnotation(\u0026#34;org.springframework.context.annotation.Configuration\u0026#34;); if(flag){ return new String[]{\u0026#34;com.itheima.bean.Dog\u0026#34;}; } return new String[]{\u0026#34;com.itheima.bean.Cat\u0026#34;}; } } 方式七：导入实现了ImportBeanDefinitionRegistrar接口的类\r​\t方式六中提供了给定类全路径类名控制bean加载的形式，如果对spring的bean的加载原理比较熟悉的小伙伴知道，其实bean的加载不是一个简简单单的对象，spring中定义了一个叫做BeanDefinition的东西，它才是控制bean初始化加载的核心。BeanDefinition接口中给出了若干种方法，可以控制bean的相关属性。说个最简单的，创建的对象是单例还是非单例，在BeanDefinition中定义了scope属性就可以控制这个。如果你感觉方式六没有给你开放出足够的对bean的控制操作，那么方式七你值得拥有。我们可以通过定义一个类，然后实现ImportBeanDefinitionRegistrar接口的方式定义bean，并且还可以让你对bean的初始化进行更加细粒度的控制，不过对于新手并不是很友好。忽然给你开放了若干个操作，还真不知道如何下手。\npublic class MyRegistrar implements ImportBeanDefinitionRegistrar { @Override public void registerBeanDefinitions(AnnotationMetadata metadata, BeanDefinitionRegistry registry) { BeanDefinition beanDefinition = BeanDefinitionBuilder.rootBeanDefinition(BookServiceImpl2.class).getBeanDefinition(); registry.registerBeanDefinition(\u0026#34;bookService\u0026#34;,beanDefinition); } } 方式八：导入实现了BeanDefinitionRegistryPostProcessor接口的类\r​\t上述七种方式都是在容器初始化过程中进行bean的加载或者声明，但是这里有一个bug。这么多种方式，它们之间如果有冲突怎么办？谁能有最终裁定权？这是个好问题，当某种类型的bean被接二连三的使用各种方式加载后，在你对所有加载方式的加载顺序没有完全理解清晰之前，你还真不知道最后谁说了算。即便你理清楚了，保不齐和你一起开发的猪队友又添加了一个bean，得嘞，这下就热闹了。\n​\tspring挥舞它仲裁者的大刀来了一个致命一击，都别哔哔了，我说了算，BeanDefinitionRegistryPostProcessor，看名字知道，BeanDefinition意思是bean定义，Registry注册的意思，Post后置，Processor处理器，全称bean定义后处理器，干啥的？在所有bean注册都折腾完后，它把最后一道关，说白了，它说了算，这下消停了，它是最后一个运行的。\npublic class MyPostProcessor implements BeanDefinitionRegistryPostProcessor { @Override public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) throws BeansException { BeanDefinition beanDefinition = BeanDefinitionBuilder.rootBeanDefinition(BookServiceImpl4.class).getBeanDefinition(); registry.registerBeanDefinition(\u0026#34;bookService\u0026#34;,beanDefinition); } } ​\t总体上来说，上面介绍了各种各样的bean的注册加载初始化方式，脑子里建立个概念吧，方式很多，spring源码中大量运用各种方式。复习的内容就先说到这里。\n总结\nbean的定义由前期xml配置逐步演化成注解配置，本质是一样的，都是通过反射机制加载类名后创建对象，对象就是spring管控的bean @Import注解可以指定加载某一个类作为spring管控的bean，如果被加载的类中还具有@Bean相关的定义，会被一同加载 spring开放出了若干种可编程控制的bean的初始化方式，通过分支语句由固定的加载bean转成了可以选择bean是否加载或者选择加载哪一种bean YL-1-2.bean的加载控制\r​\t前面复习bean的加载时，提出了有关加载控制的方式，其中手工注册bean，ImportSelector接口，ImportBeanDefinitionRegistrar接口，BeanDefinitionRegistryPostProcessor接口都可以控制bean的加载，这一节就来说说这些加载控制。\n​\t企业级开发中不可能在spring容器中进行bean的饱和式加载的。什么是饱和式加载，就是不管用不用，全部加载。比如jdk中有两万个类，那就加载两万个bean，显然是不合理的，因为你压根就不会使用其中大部分的bean。那合理的加载方式是什么？肯定是必要性加载，就是用什么加载什么。继续思考，加载哪些bean通常受什么影响呢？最容易想的就是你要用什么技术，就加载对应的bean。用什么技术意味着什么？就是加载对应技术的类。所以在spring容器中，通过判定是否加载了某个类来控制某些bean的加载是一种常见操作。下例给出了对应的代码实现，其实思想很简单，先判断一个类的全路径名是否能够成功加载，加载成功说明有这个类，那就干某项具体的工作，否则就干别的工作。\npublic class MyImportSelector implements ImportSelector { @Override public String[] selectImports(AnnotationMetadata importingClassMetadata) { try { Class\u0026lt;?\u0026gt; clazz = Class.forName(\u0026#34;com.itheima.bean.Mouse\u0026#34;); if(clazz != null) { return new String[]{\u0026#34;com.itheima.bean.Cat\u0026#34;}; } } catch (ClassNotFoundException e) { // e.printStackTrace(); return new String[0]; } return null; } } ​\t通过上述的分析，可以看到此类操作将成为企业级开发中的常见操作，于是springboot将把这些常用操作给我们做了一次封装。这种逻辑判定你开发者就别搞了，我springboot信不过你这种新手开发者，我给你封装一下，做几个注解，你填参数吧，耶，happy。\n​\t下例使用@ConditionalOnClass注解实现了当虚拟机中加载了com.itheima.bean.Wolf类时加载对应的bean。比较一下上面的代码和下面的代码，有没有感觉很清爽。其实此类注解还有很多。\n@Bean @ConditionalOnClass(name = \u0026#34;com.itheima.bean.Wolf\u0026#34;) public Cat tom(){ return new Cat(); } ​\t@ConditionalOnMissingClass注解控制虚拟机中没有加载指定的类才加载对应的bean。\n@Bean @ConditionalOnMissingClass(\u0026#34;com.itheima.bean.Dog\u0026#34;) public Cat tom(){ return new Cat(); } ​\t这种条件还可以做并且的逻辑关系，写2个就是2个条件都成立，写多个就是多个条件都成立。\n@Bean @ConditionalOnClass(name = \u0026#34;com.itheima.bean.Wolf\u0026#34;) @ConditionalOnMissingClass(\u0026#34;com.itheima.bean.Mouse\u0026#34;) public Cat tom(){ return new Cat(); } ​\t除了判定是否加载类，还可以对当前容器类型做判定，下例是判定当前容器环境是否是web环境。\n@Bean @ConditionalOnWebApplication public Cat tom(){ return new Cat(); } ​\t下面是判定容器环境是否是非web环境。\n@Bean @ConditionalOnNotWebApplication public Cat tom(){ return new Cat(); } ​\t当然还可以判定是否加载了指定名称的bean，这种有什么用呢？太有用了。比如当前容器中已经提供了jdbcTemplate对应的bean，你还需要再加载一个全新的jdbcTemplate的bean吗？没有必要了嘛。spring说，如果你自己写的话，我就不帮你操这份心了，如果你没写，我再给你提供。自适应，自适应，明白？没有的话就提供给你，有的话就用你自己的，是不是很帅？\n@Bean @ConditionalOnBean(name=\u0026#34;jerry\u0026#34;) public Cat tom(){ return new Cat(); } ​\t以下就是判定当前是否加载了mysql的驱动类，如果加载了，我就给你搞一个Druid的数据源对象出来，完美！\npublic class SpringConfig { @Bean @ConditionalOnClass(name=\u0026#34;com.mysql.jdbc.Driver\u0026#34;) public DruidDataSource dataSource(){ return new DruidDataSource(); } } ​\t其中springboot的bean加载控制注解还有很多，这里就不一一列举了，最常用的判定条件就是根据类是否加载来进行控制。\n总结\nspringboot定义了若干种控制bean加载的条件设置注解，由spring固定加载bean变成了可以根据情况选择性的加载bean YL-1-3.bean的依赖属性配置管理\r​\tbean的加载及加载控制已经搞完了，下面研究一下bean内部的事情。bean在运行的时候，实现对应的业务逻辑时有可能需要开发者提供一些设置值，有就是属性了。如果使用构造方法将参数固定，灵活性不足，这个时候就可以使用前期学习的bean的属性配置相关的知识进行灵活的配置了。先通过yml配置文件，设置bean运行需要使用的配置信息。\ncartoon: cat: name: \u0026#34;图多盖洛\u0026#34; age: 5 mouse: name: \u0026#34;泰菲\u0026#34; age: 1 ​\t然后定义一个封装属性的专用类，加载配置属性，读取对应前缀相关的属性值。\n@ConfigurationProperties(prefix = \u0026#34;cartoon\u0026#34;) @Data public class CartoonProperties { private Cat cat; private Mouse mouse; } ​\t最后在使用的位置注入对应的配置即可。\n@EnableConfigurationProperties(CartoonProperties.class) public class CartoonCatAndMouse{ @Autowired private CartoonProperties cartoonProperties; } ​\t建议在业务类上使用@EnableConfigurationProperties声明bean，这样在不使用这个类的时候，也不会无故加载专用的属性配置类CartoonProperties，减少spring管控的资源数量。\n总结\n@EnableConfigurationProperties(CartoonProperties.class)是为了不在CartoonProperties上面加 @component,\rEnableConfigurationProperties将 CartoonCatAndMouse和 CartoonProperties关联起来, 前者加载后者才加载, 防止全部加载\rCartoonProperties 不是@component\rCartoonCatAndMouse 也没有 @component(防止全部加载)\r在启动类上面加上 @Import(CartoonCatAndMouse) bean的运行如果需要外部设置值，建议将设置值封装成专用的属性类* * * * Properties 设置属性类加载指定前缀的配置信息 在需要使用属性类的位置通过注解@EnableConfigurationProperties加载bean，而不要直接在属性配置类上定义bean，减少资源加载的数量，因需加载而不要饱和式加载。 YL-1-4.自动配置原理（工作流程）\r​\t经过前面的知识复习，下面终于进入到了本章核心内容的学习，自动配置原理。原理谈不上，就是自动配置的工作流程。\n​\t啥叫自动配置呢？简单说就是springboot根据我们开发者的行为猜测你要做什么事情，然后把你要用的bean都给你准备好。听上去是不是很神奇？其实非常简单，前面复习的东西都已经讲完了。springboot咋做到的呢？就是看你导入了什么类，就知道你想干什么了。然后把你有可能要用的bean（注意是有可能）都给你加载好，你直接使用就行了，springboot把所需要的一切工作都做完了。\n​\t自动配置的意义就是加速开发效率，将开发者使用某种技术时需要使用的bean根据情况提前加载好，实现自动配置的效果。当然，开发者有可能需要提供必要的参数，比如你要用mysql技术，导入了mysql的坐标，springboot就知道了你要做数据库操作，一系列的数据库操作相关的bean都给你提前声明好，但是你要告诉springboot你到底用哪一个数据库，像什么IP地址啊，端口啊，你不告诉spirngboot，springboot就无法帮你把自动配置相关的工作做完。\n​\t而这种思想其实就是在日常的开发过程中根据开发者的习惯慢慢抽取得到了。整体过程分为2个阶段：\n​\t阶段一：准备阶段\nspringboot的开发人员先大量收集Spring开发者的编程习惯，整理开发过程每一个程序经常使用的技术列表，形成一个技术集A\n收集常用技术(技术集A)的使用参数，不管你用什么常用设置，我用什么常用设置，统统收集起来整理一下，得到开发过程中每一个技术的常用设置，形成每一个技术对应的设置集B\n阶段二：加载阶段\nspringboot初始化Spring容器基础环境，读取用户的配置信息，加载用户自定义的bean和导入的其他坐标，形成初始化环境\nspringboot将技术集A包含的所有技术在SpringBoot启动时默认全部加载，这时肯定加载的东西有一些是无效的，没有用的\nspringboot会对技术集A中每一个技术约定出启动这个技术对应的条件，并设置成按条件加载，由于开发者导入了一些bean和其他坐标，也就是与初始化环境，这个时候就可以根据这个初始化环境与springboot的技术集A进行比对了，哪个匹配上加载哪个\n因为有些技术不做配置就无法工作，所以springboot开始对设置集B下手了。它统计出各个国家各个行业的开发者使用某个技术时最常用的设置是什么，然后把这些设置作为默认值直接设置好，并告诉开发者当前设置我已经给你搞了一套，你要用可以直接用，这样可以减少开发者配置参数的工作量\n但是默认配置不一定能解决问题，于是springboot开放修改设置集B的接口，可以由开发者根据需要决定是否覆盖默认配置\n​\t以上这些仅仅是一个思想，落地到代码实现阶段就要好好思考一下怎么实现了。假定我们想自己实现自动配置的功能，都要做哪些工作呢？\n首先指定一个技术X，我们打算让技术X具备自动配置的功能，这个技术X可以是任意功能，这个技术隶属于上面描述的技术集A public class CartoonCatAndMouse{ } 然后找出技术X使用过程中的常用配置Y，这个配置隶属于上面表述的设置集B cartoon: cat: name: \u0026#34;图多盖洛\u0026#34; age: 5 mouse: name: \u0026#34;泰菲\u0026#34; age: 1 将常用配置Y设计出对应的yml配置书写格式，然后定义一个属性类封装对应的配置属性，这个过程其实就是上一节咱们做的bean的依赖属性管理，一模一样 @ConfigurationProperties(prefix = \u0026#34;cartoon\u0026#34;) @Data public class CartoonProperties { private Cat cat; private Mouse mouse; } 最后做一个配置类，当这个类加载的时候就可以初始化对应的功能bean，并且可以加载到对应的配置 @EnableConfigurationProperties(CartoonProperties.class) public class CartoonCatAndMouse implements ApplicationContextAware { private CartoonProperties cartoonProperties; } 当然，你也可以为当前自动配置类设置上激活条件，例如使用@CondtionOn* * * * 为其设置加载条件 @ConditionalOnClass(name=\u0026#34;org.springframework.data.redis.core.RedisOperations\u0026#34;) @EnableConfigurationProperties(CartoonProperties.class) public class CartoonCatAndMouse implements ApplicationContextAware { private CartoonProperties cartoonProperties; } ​\t做到这里都已经做完了，但是遇到了一个全新的问题，如何让springboot启动的时候去加载这个类呢？如果不加载的话，我们做的条件判定，做的属性加载这些全部都失效了。springboot为我们开放了一个配置入口，在配置目录中创建META-INF目录，并创建spring.factories文件，在其中添加设置，说明哪些类要启动自动配置就可以了。\n# Auto Configure\rorg.springframework.boot.autoconfigure.EnableAutoConfiguration=\\\rcom.itheima.bean.CartoonCatAndMouse ​\t其实这个文件就做了一件事，通过这种配置的方式加载了指定的类。转了一圈，就是个普通的bean的加载，和最初使用xml格式加载bean几乎没有区别，格式变了而已。那自动配置的核心究竟是什么呢？自动配置其实是一个小的生态，可以按照如下思想理解：\n自动配置从根本上来说就是一个bean的加载 通过bean加载条件的控制给开发者一种感觉，自动配置是自适应的，可以根据情况自己判定，但实际上就是最普通的分支语句的应用，这是蒙蔽我们双眼的第一层面纱 使用bean的时候，如果不设置属性，就有默认值，如果不想用默认值，就可以自己设置，也就是可以修改部分或者全部参数，感觉这个过程好屌，也是一种自适应的形式，其实还是需要使用分支语句来做判断的，这是蒙蔽我们双眼的第二层面纱 springboot技术提前将大量开发者有可能使用的技术提前做好了，条件也写好了，用的时候你导入了一个坐标，对应技术就可以使用了，其实就是提前帮我们把spring.factories文件写好了，这是蒙蔽我们双眼的第三层面纱 ​\t你在不知道自动配置这个知识的情况下，经过上面这一二三，你当然觉得自动配置是一种特别牛的技术，但是一窥究竟后发现，也就那么回事。而且现在springboot程序启动时，在后台偷偷的做了这么多次检测，这么多种情况判定，不用问了，效率一定是非常低的，毕竟它要检测100余种技术是否在你程序中使用。\n​\t以上内容是自动配置的工作流程。\n总结\nspringboot启动时先加载spring.factories文件中的org.springframework.boot.autoconfigure.EnableAutoConfiguration配置项，将其中配置的所有的类都加载成bean 在加载bean的时候，bean对应的类定义上都设置有加载条件，因此有可能加载成功，也可能条件检测失败不加载bean 对于可以正常加载成bean的类，通常会通过@EnableConfigurationProperties注解初始化对应的配置属性类并加载对应的配置 配置属性类上通常会通过@ConfigurationProperties加载指定前缀的配置，当然这些配置通常都有默认值。如果没有默认值，就强制你必须配置后使用了 YL-1-5.变更自动配置\r​\t知道了自动配置的执行过程，下面就可以根据这个自动配置的流程做一些高级定制了。例如系统默认会加载100多种自动配置的技术，如果我们先手工干预此工程，禁用自动配置是否可行呢？答案一定是可以的。方式还挺多：\n方式一：通过yaml配置设置排除指定的自动配置类\nspring: autoconfigure: exclude: - org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration 方式二：通过注解参数排除自动配置类\n@EnableAutoConfiguration(excludeName = \u0026#34;\u0026#34;,exclude = {}) 方式三：排除坐标（应用面较窄）\n如果当前自动配置中包含有更多的自动配置功能，也就是一个套娃的效果。此时可以通过检测条件的控制来管理自动配置是否启动。例如web程序启动时会自动启动tomcat服务器，可以通过排除坐标的方式，让加载tomcat服务器的条件失效。不过需要提醒一点，你把tomcat排除掉，记得再加一种可以运行的服务器。\n\u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-web\u0026lt;/artifactId\u0026gt; \u0026lt;!--web起步依赖环境中，排除Tomcat起步依赖，匹配自动配置条件--\u0026gt; \u0026lt;exclusions\u0026gt; \u0026lt;exclusion\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-tomcat\u0026lt;/artifactId\u0026gt; \u0026lt;/exclusion\u0026gt; \u0026lt;/exclusions\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!--添加Jetty起步依赖，匹配自动配置条件--\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-jetty\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; 总结\nspringboot的自动配置并不是必然运行的，可以通过配置的形式干预是否启用对应的自动配置功能 YL-2.自定义starter开发\r​\t自动配置学习完后，我们就可以基于自动配置的特性，开发springboot技术中最引以为傲的功能了，starter。其实通过前期学习，我们发现用什么技术直接导入对应的starter，然后就实现了springboot整合对应技术，再加上一些简单的配置，就可以直接使用了。这种设计方式对开发者非常友好，本章就通过一个案例的制作，开发自定义starter来实现自定义功能的快捷添加。\nYL-2-1.案例：记录系统访客独立IP访问次数\r​\t本案例的功能是统计网站独立IP访问次数的功能，并将访问信息在后台持续输出。整体功能是在后台每10秒输出一次监控信息（格式：IP+访问次数） ，当用户访问网站时，对用户的访问行为进行统计。\n​\t例如：张三访问网站功能15次，IP地址：192.168.0.135，李四访问网站功能20次，IP地址：61.129.65.248。那么在网站后台就输出如下监控信息，此信息每10秒刷新一次。\nIP访问监控 +-----ip-address-----+--num--+ | 192.168.0.135 | 15 | | 61.129.65.248 | 20 | +--------------------+-------+ ​\t在进行具体制作之前，先对功能做具体的分析\n数据记录在什么位置\n最终记录的数据是一个字符串（IP地址）对应一个数字（访问次数），此处可以选择的数据存储模型可以使用java提供的map模型，也就是key-value的键值对模型，或者具有key-value键值对模型的存储技术，例如redis技术。本案例使用map作为实现方案，有兴趣的小伙伴可以使用redis作为解决方案。\n统计功能运行位置，因为每次web请求都需要进行统计，因此使用拦截器会是比较好的方案，本案例使用拦截器来实现。不过在制作初期，先使用调用的形式进行测试，等功能完成了，再改成拦截器的实现方案。\n为了提升统计数据展示的灵活度，为统计功能添加配置项。输出频度，输出的数据格式，统计数据的显示模式均可以通过配置实现调整。\n输出频度，默认10秒 数据特征：累计数据 / 阶段数据，默认累计数据 输出格式：详细模式 / 极简模式 ​\t在下面的制作中，分成若干个步骤实现。先完成最基本的统计功能的制作，然后开发出统计报表，接下来把所有的配置都设置好，最后将拦截器功能实现，整体功能就做完了。\nYL-2-2.IP计数业务功能开发（自定义starter）\r​\t本功能最终要实现的效果是在现有的项目中导入一个starter，对应的功能就添加上了，删除掉对应的starter，功能就消失了，要求功能要与原始项目完全解耦。因此需要开发一个独立的模块，制作对应功能。\n步骤一：创建全新的模块，定义业务功能类\n​\t功能类的制作并不复杂，定义一个业务类，声明一个Map对象，用于记录ip访问次数，key是ip地址，value是访问次数\npublic class IpCountService { private Map\u0026lt;String,Integer\u0026gt; ipCountMap = new HashMap\u0026lt;String,Integer\u0026gt;(); } ​\t有些小伙伴可能会有疑问，不设置成静态的，如何在每次请求时进行数据共享呢？记得，当前类加载成bean以后是一个单例对象，对象都是单例的，哪里存在多个对象共享变量的问题。\n步骤二：制作统计功能\n​\t制作统计操作对应的方法，每次访问后对应ip的记录次数+1。需要分情况处理，如果当前没有对应ip的数据，新增一条数据，否则就修改对应key的值+1即可\npublic class IpCountService { private Map\u0026lt;String,Integer\u0026gt; ipCountMap = new HashMap\u0026lt;String,Integer\u0026gt;(); public void count(){ //每次调用当前操作，就记录当前访问的IP，然后累加访问次数 //1.获取当前操作的IP地址 String ip = null; //2.根据IP地址从Map取值，并递增 Integer count = ipCountMap.get(ip); if(count == null){ ipCountMap.put(ip,1); }else{ ipCountMap.put(ip,count + 1); } } } ​\t因为当前功能最终导入到其他项目中进行，而导入当前功能的项目是一个web项目，可以从容器中直接获取请求对象，因此获取IP地址的操作可以通过自动装配得到请求对象，然后获取对应的访问IP地址。\npublic class IpCountService { private Map\u0026lt;String,Integer\u0026gt; ipCountMap = new HashMap\u0026lt;String,Integer\u0026gt;(); @Autowired //当前的request对象的注入工作由使用当前starter的工程提供自动装配 private HttpServletRequest httpServletRequest; public void count(){ //每次调用当前操作，就记录当前访问的IP，然后累加访问次数 //1.获取当前操作的IP地址 String ip = httpServletRequest.getRemoteAddr(); //2.根据IP地址从Map取值，并递增 Integer count = ipCountMap.get(ip); if(count == null){ ipCountMap.put(ip,1); }else{ ipCountMap.put(ip,count + 1); } } } 步骤三：定义自动配置类\n​\t我们需要做到的效果是导入当前模块即开启此功能，因此使用自动配置实现功能的自动装载，需要开发自动配置类在启动项目时加载当前功能。\npublic class IpAutoConfiguration { @Bean public IpCountService ipCountService(){ return new IpCountService(); } } ​\t自动配置类需要在spring.factories文件中做配置方可自动运行。\n# Auto Configure org.springframework.boot.autoconfigure.EnableAutoConfiguration=cn.itcast.autoconfig.IpAutoConfiguration 步骤四：在原始项目中模拟调用，测试功能\n​\t原始调用项目中导入当前开发的starter\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;cn.itcast\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;ip_spring_boot_starter\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;0.0.1-SNAPSHOT\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; ​\t推荐选择调用方便的功能做测试，推荐使用分页操作，当然也可以换其他功能位置进行测试。\n@RestController @RequestMapping(\u0026#34;/books\u0026#34;) public class BookController { @Autowired private IpCountService ipCountService; @GetMapping(\u0026#34;{currentPage}/{pageSize}\u0026#34;) public R getPage(@PathVariable int currentPage,@PathVariable int pageSize,Book book){ ipCountService.count(); IPage\u0026lt;Book\u0026gt; page = bookService.getPage(currentPage, pageSize,book); if( currentPage \u0026gt; page.getPages()){ page = bookService.getPage((int)page.getPages(), pageSize,book); } return new R(true, page); } } 温馨提示\n​\t由于当前制作的功能需要在对应的调用位置进行坐标导入，因此必须保障仓库中具有当前开发的功能，所以每次原始代码修改后，需要重新编译并安装到仓库中。为防止问题出现，建议每次安装之前先clean然后install，保障资源进行了更新。切记切记！！\n当前效果\n​\t每次调用分页操作后，可以在控制台输出当前访问的IP地址，此功能可以在count操作中添加日志或者输出语句进行测试。\nYL-2-3.定时任务报表开发\r​\t当前已经实现了在业务功能类中记录访问数据，但是还没有输出监控的信息到控制台。由于监控信息需要每10秒输出1次，因此需要使用定时器功能。可以选取第三方技术Quartz实现，也可以选择Spring内置的task来完成此功能，此处选用Spring的task作为实现方案。\n步骤一：开启定时任务功能\n​\t定时任务功能开启需要在当前功能的总配置中设置，结合现有业务设定，比较合理的位置是设置在自动配置类上。加载自动配置类即启用定时任务功能。\n@EnableScheduling public class IpAutoConfiguration { @Bean public IpCountService ipCountService(){ return new IpCountService(); } } 步骤二：制作显示统计数据功能\n​\t定义显示统计功能的操作print()，并设置定时任务，当前设置每5秒运行一次统计数据。\npublic class IpCountService { private Map\u0026lt;String,Integer\u0026gt; ipCountMap = new HashMap\u0026lt;String,Integer\u0026gt;(); @Scheduled(cron = \u0026#34;0/5 * * * * ?\u0026#34;) public void print(){ System.out.println(\u0026#34; IP访问监控\u0026#34;); System.out.println(\u0026#34;+-----ip-address-----+--num--+\u0026#34;); for (Map.Entry\u0026lt;String, Integer\u0026gt; entry : ipCountMap.entrySet()) { String key = entry.getKey(); Integer value = entry.getValue(); System.out.println(String.format(\u0026#34;|%18s |%5d |\u0026#34;,key,value)); } System.out.println(\u0026#34;+--------------------+-------+\u0026#34;); } } ​\t其中关于统计报表的显示信息拼接可以使用各种形式进行，此处使用String类中的格式化字符串操作进行，学习者可以根据自己的喜好调整实现方案。\n温馨提示\n​\t每次运行效果之前先clean然后install，切记切记！！\n当前效果\n​\t每次调用分页操作后，可以在控制台看到统计数据，到此基础功能已经开发完毕。\nYL-2-4.使用属性配置设置功能参数\r​\t由于当前报表显示的信息格式固定，为提高报表信息显示的灵活性，需要通过yml文件设置参数，控制报表的显示格式。\n步骤一：定义参数格式\n​\t设置3个属性，分别用来控制显示周期（cycle），阶段数据是否清空（cycleReset），数据显示格式（model）\ntools: ip: cycle: 10 cycleReset: false model: \u0026#34;detail\u0026#34; 步骤二：定义封装参数的属性类，读取配置参数\n​\t为防止项目组定义的参数种类过多，产生冲突，通常设置属性前缀会至少使用两级属性作为前缀进行区分。\n​\t日志输出模式是在若干个类别选项中选择某一项，对于此种分类性数据建议制作枚举定义分类数据，当然使用字符串也可以。\n@ConfigurationProperties(prefix = \u0026#34;tools.ip\u0026#34;) public class IpProperties { /** * 日志显示周期 */ private Long cycle = 5L; /** * 是否周期内重置数据 */ private Boolean cycleReset = false; /** * 日志输出模式 detail：详细模式 simple：极简模式 */ private String model = LogModel.DETAIL.value; public enum LogModel{ DETAIL(\u0026#34;detail\u0026#34;), SIMPLE(\u0026#34;simple\u0026#34;); private String value; LogModel(String value) { this.value = value; } public String getValue() { return value; } } } 步骤三：加载属性类\n@EnableScheduling @EnableConfigurationProperties(IpProperties.class) public class IpAutoConfiguration { @Bean public IpCountService ipCountService(){ return new IpCountService(); } } 步骤四：应用配置属性\n​\t在应用配置属性的功能类中，使用自动装配加载对应的配置bean，然后使用配置信息做分支处理。\n​\t注意：清除数据的功能一定要在输出后运行，否则每次查阅的数据均为空白数据。\npublic class IpCountService { private Map\u0026lt;String,Integer\u0026gt; ipCountMap = new HashMap\u0026lt;String,Integer\u0026gt;(); @Autowired private IpProperties ipProperties; @Scheduled(cron = \u0026#34;0/5 * * * * ?\u0026#34;) public void print(){ if(ipProperties.getModel().equals(IpProperties.LogModel.DETAIL.getValue())){ System.out.println(\u0026#34; IP访问监控\u0026#34;); System.out.println(\u0026#34;+-----ip-address-----+--num--+\u0026#34;); for (Map.Entry\u0026lt;String, Integer\u0026gt; entry : ipCountMap.entrySet()) { String key = entry.getKey(); Integer value = entry.getValue(); System.out.println(String.format(\u0026#34;|%18s |%5d |\u0026#34;,key,value)); } System.out.println(\u0026#34;+--------------------+-------+\u0026#34;); }else if(ipProperties.getModel().equals(IpProperties.LogModel.SIMPLE.getValue())){ System.out.println(\u0026#34; IP访问监控\u0026#34;); System.out.println(\u0026#34;+-----ip-address-----+\u0026#34;); for (String key: ipCountMap.keySet()) { System.out.println(String.format(\u0026#34;|%18s |\u0026#34;,key)); } System.out.println(\u0026#34;+--------------------+\u0026#34;); } //阶段内统计数据归零 if(ipProperties.getCycleReset()){ ipCountMap.clear(); } } } 温馨提示\n​\t每次运行效果之前先clean然后install，切记切记！！\n当前效果\n​\t在web程序端可以通过控制yml文件中的配置参数对统计信息进行格式控制。但是数据显示周期还未进行控制。\nYL-2-5.使用属性配置设置定时器参数\r​\t在使用属性配置中的显示周期数据时，遇到了一些问题。由于无法在@Scheduled注解上直接使用配置数据，改用曲线救国的方针，放弃使用@EnableConfigurationProperties注解对应的功能，改成最原始的bean定义格式。\n步骤一：@Scheduled注解使用#{}读取bean属性值\n​\t此处读取bean名称为ipProperties的bean的cycle属性值\n@Scheduled(cron = \u0026#34;0/#{ipProperties.cycle} * * * * ?\u0026#34;) public void print(){ } 步骤二：属性类定义bean并指定bean的访问名称\n​\t如果此处不设置bean的访问名称，spring会使用自己的命名生成器生成bean的长名称，无法实现属性的读取\n@Component(\u0026#34;ipProperties\u0026#34;) @ConfigurationProperties(prefix = \u0026#34;tools.ip\u0026#34;) public class IpProperties { } 步骤三：弃用@EnableConfigurationProperties注解对应的功能，改为导入bean的形式加载配置属性类\n@EnableScheduling //@EnableConfigurationProperties(IpProperties.class) @Import(IpProperties.class) public class IpAutoConfiguration { @Bean public IpCountService ipCountService(){ return new IpCountService(); } } 温馨提示\n​\t每次运行效果之前先clean然后install，切记切记！！\n当前效果\n​\t在web程序端可以通过控制yml文件中的配置参数对统计信息的显示周期进行控制\nYL-2-6.拦截器开发\r​\t基础功能基本上已经完成了制作，下面进行拦截器的开发。开发时先在web工程中制作，然后将所有功能挪入starter模块中\n步骤一：开发拦截器\n​\t使用自动装配加载统计功能的业务类，并在拦截器中调用对应功能\npublic class IpCountInterceptor implements HandlerInterceptor { @Autowired private IpCountService ipCountService; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { ipCountService.count(); return true; } } 步骤二：配置拦截器\n​\t配置mvc拦截器，设置拦截对应的请求路径。此处拦截所有请求，用户可以根据使用需要设置要拦截的请求。甚至可以在此处加载IpCountProperties中的属性，通过配置设置拦截器拦截的请求。\n@Configuration public class SpringMvcConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(ipCountInterceptor()).addPathPatterns(\u0026#34;/**\u0026#34;); } @Bean public IpCountInterceptor ipCountInterceptor(){ return new IpCountInterceptor(); } } 温馨提示\n​\t每次运行效果之前先clean然后install，切记切记！！\n当前效果\n​\t在web程序端导入对应的starter后功能开启，去掉坐标后功能消失，实现自定义starter的效果。\n​\t到此，当前案例全部完成。自定义 starter 的开发其实在第一轮开发中就已经完成了，本质上就是创建独立模块并导出独立功能，在需要使用的位置导入对应的 starter 即可。如果是在企业中开发，记得不仅要将开发完成的 starter 模块 install 到本地仓库，开发完毕后还要 deploy 到私服上，否则别人就无法使用。\nYL-2-7.功能性完善——开启yml提示功能\r​\t我们在使用springboot的配置属性时，都可以看到提示，尤其是导入了对应的starter后，也会有对应的提示信息出现。但是现在我们的starter没有对应的提示功能，这种设定就非常的不友好，本节解决自定义starter功能如何开启配置提示的问题。\n​\tspringboot提供有专用的工具实现此功能，仅需要导入下列坐标。\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-configuration-processor\u0026lt;/artifactId\u0026gt; \u0026lt;optional\u0026gt;true\u0026lt;/optional\u0026gt; \u0026lt;/dependency\u0026gt; ​\t程序编译后，在META-INF目录中会生成对应的提示文件，然后拷贝生成出的文件到自己开发的META-INF目录中，并对其进行编辑。打开生成的文件，可以看到如下信息。其中groups属性定义了当前配置的提示信息总体描述，当前配置属于哪一个属性封装类，properties属性描述了当前配置中每一个属性的具体设置，包含名称、类型、描述、默认值等信息。hints属性默认是空白的，没有进行设置。hints属性可以参考springboot源码中的制作，设置当前属性封装类专用的提示信息，下例中为日志输出模式属性model设置了两种可选提示信息。\n{ \u0026#34;groups\u0026#34;: [ { \u0026#34;name\u0026#34;: \u0026#34;tools.ip\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;cn.itcast.properties.IpProperties\u0026#34;, \u0026#34;sourceType\u0026#34;: \u0026#34;cn.itcast.properties.IpProperties\u0026#34; } ], \u0026#34;properties\u0026#34;: [ { \u0026#34;name\u0026#34;: \u0026#34;tools.ip.cycle\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;java.lang.Long\u0026#34;, \u0026#34;description\u0026#34;: \u0026#34;日志显示周期\u0026#34;, \u0026#34;sourceType\u0026#34;: \u0026#34;cn.itcast.properties.IpProperties\u0026#34;, \u0026#34;defaultValue\u0026#34;: 5 }, { \u0026#34;name\u0026#34;: \u0026#34;tools.ip.cycle-reset\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;java.lang.Boolean\u0026#34;, \u0026#34;description\u0026#34;: \u0026#34;是否周期内重置数据\u0026#34;, \u0026#34;sourceType\u0026#34;: \u0026#34;cn.itcast.properties.IpProperties\u0026#34;, \u0026#34;defaultValue\u0026#34;: false }, { \u0026#34;name\u0026#34;: \u0026#34;tools.ip.model\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;java.lang.String\u0026#34;, \u0026#34;description\u0026#34;: \u0026#34;日志输出模式 detail：详细模式 simple：极简模式\u0026#34;, \u0026#34;sourceType\u0026#34;: \u0026#34;cn.itcast.properties.IpProperties\u0026#34; } ], \u0026#34;hints\u0026#34;: [ { \u0026#34;name\u0026#34;: \u0026#34;tools.ip.model\u0026#34;, \u0026#34;values\u0026#34;: [ { \u0026#34;value\u0026#34;: \u0026#34;detail\u0026#34;, \u0026#34;description\u0026#34;: \u0026#34;详细模式.\u0026#34; }, { \u0026#34;value\u0026#34;: \u0026#34;simple\u0026#34;, \u0026#34;description\u0026#34;: \u0026#34;极简模式.\u0026#34; } ] } ] } 总结\n自定义starter其实就是做一个独立的功能模块，核心技术是利用自动配置的效果在加载模块后加载对应的功能 通常会为自定义starter的自动配置功能添加足够的条件控制，而不会做成100%加载对功能的效果 本例中使用map保存数据，如果换用redis方案，在starter开发模块中就要导入redis对应的starter 对于配置属性务必开启提示功能，否则使用者无法感知配置应该如何书写 YL-3.SpringBoot程序启动流程解析\r​\t原理篇学习到这里即将结束，最后一章说一下springboot程序的启动流程。对于springboot技术来说，它用于加速spring程序的开发，核心本质还是spring程序的运行，所以于其说是springboot程序的启动流程，不如说是springboot对spring程序的启动流程做了哪些更改。\n​\t其实不管是springboot程序还是spring程序，启动过程本质上都是在做容器的初始化，并将对应的bean初始化出来放入容器。在spring环境中，每个bean的初始化都要开发者自己添加设置，但是切换成springboot程序后，自动配置功能的添加帮助开发者提前预设了很多bean的初始化过程，加上各种各样的参数设置，使得整体初始化过程显得略微复杂，但是核心本质还是在做一件事，初始化容器。作为开发者只要搞清楚springboot提供了哪些参数设置的环节，同时初始化容器的过程中都做了哪些事情就行了。\n​\tspringboot初始化的参数根据参数的提供方，划分成如下3个大类，每个大类的参数又被封装了各种各样的对象，具体如下：\n环境属性（Environment） 系统配置（spring.factories） 参数（Arguments、application.properties） ​\t以下通过代码流向介绍了springboot程序启动时每一环节做的具体事情。\nSpringboot30StartupApplication【10】-\u0026gt;SpringApplication.run(Springboot30StartupApplication.class, args); SpringApplication【1332】-\u0026gt;return run(new Class\u0026lt;?\u0026gt;[] { primarySource }, args); SpringApplication【1343】-\u0026gt;return new SpringApplication(primarySources).run(args); SpringApplication【1343】-\u0026gt;SpringApplication(primarySources) # 加载各种配置信息，初始化各种配置对象 SpringApplication【266】-\u0026gt;this(null, primarySources); SpringApplication【280】-\u0026gt;public SpringApplication(ResourceLoader resourceLoader, Class\u0026lt;?\u0026gt;... primarySources) SpringApplication【281】-\u0026gt;this.resourceLoader = resourceLoader; # 初始化资源加载器 SpringApplication【283】-\u0026gt;this.primarySources = new LinkedHashSet\u0026lt;\u0026gt;(Arrays.asList(primarySources)); # 初始化配置类的类名信息（格式转换） SpringApplication【284】-\u0026gt;this.webApplicationType = WebApplicationType.deduceFromClasspath(); # 确认当前容器加载的类型 SpringApplication【285】-\u0026gt;this.bootstrapRegistryInitializers = getBootstrapRegistryInitializersFromSpringFactories(); # 获取系统配置引导信息 SpringApplication【286】-\u0026gt;setInitializers((Collection) getSpringFactoriesInstances(ApplicationContextInitializer.class)); # 获取ApplicationContextInitializer.class对应的实例 SpringApplication【287】-\u0026gt;setListeners((Collection) getSpringFactoriesInstances(ApplicationListener.class)); # 初始化监听器，对初始化过程及运行过程进行干预 SpringApplication【288】-\u0026gt;this.mainApplicationClass = deduceMainApplicationClass(); # 初始化了引导类类名信息，备用 SpringApplication【1343】-\u0026gt;new SpringApplication(primarySources).run(args) # 初始化容器，得到ApplicationContext对象 SpringApplication【323】-\u0026gt;StopWatch stopWatch = new StopWatch(); # 设置计时器 SpringApplication【324】-\u0026gt;stopWatch.start(); # 计时开始 SpringApplication【325】-\u0026gt;DefaultBootstrapContext bootstrapContext = createBootstrapContext(); # 系统引导信息对应的上下文对象 SpringApplication【327】-\u0026gt;configureHeadlessProperty(); # 模拟输入输出信号，避免出现因缺少外设导致的信号传输失败，进而引发错误（模拟显示器，键盘，鼠标...） java.awt.headless=true SpringApplication【328】-\u0026gt;SpringApplicationRunListeners listeners = getRunListeners(args); # 获取当前注册的所有监听器 SpringApplication【329】-\u0026gt;listeners.starting(bootstrapContext, this.mainApplicationClass); # 监听器执行了对应的操作步骤 SpringApplication【331】-\u0026gt;ApplicationArguments applicationArguments = new DefaultApplicationArguments(args); # 获取参数 SpringApplication【333】-\u0026gt;ConfigurableEnvironment environment = prepareEnvironment(listeners, bootstrapContext, applicationArguments); # 将前期读取的数据加载成了一个环境对象，用来描述信息 SpringApplication【333】-\u0026gt;configureIgnoreBeanInfo(environment); # 做了一个配置，备用 SpringApplication【334】-\u0026gt;Banner printedBanner = printBanner(environment); # 初始化logo SpringApplication【335】-\u0026gt;context = createApplicationContext(); # 创建容器对象，根据前期配置的容器类型进行判定并创建 SpringApplication【363】-\u0026gt;context.setApplicationStartup(this.applicationStartup); # 设置启动模式 SpringApplication【337】-\u0026gt;prepareContext(bootstrapContext, context, environment, listeners, applicationArguments, printedBanner); # 对容器进行设置，参数来源于前期的设定 SpringApplication【338】-\u0026gt;refreshContext(context); # 刷新容器环境 SpringApplication【339】-\u0026gt;afterRefresh(context, applicationArguments); # 刷新完毕后做后处理 SpringApplication【340】-\u0026gt;stopWatch.stop(); # 计时结束 SpringApplication【341】-\u0026gt;if (this.logStartupInfo) { # 判定是否记录启动时间的日志 SpringApplication【342】-\u0026gt; new StartupInfoLogger(this.mainApplicationClass).logStarted(getApplicationLog(), stopWatch); # 创建日志对应的对象，输出日志信息，包含启动时间 SpringApplication【344】-\u0026gt;listeners.started(context); # 监听器执行了对应的操作步骤 SpringApplication【345】-\u0026gt;callRunners(context, applicationArguments); # 调用运行器 SpringApplication【353】-\u0026gt;listeners.running(context); # 监听器执行了对应的操作步骤 ​\t上述过程描述了springboot程序启动过程中做的所有的事情，这个时候好奇宝宝们就会提出一个问题。如果想干预springboot的启动过程，比如自定义一个数据库环境检测的程序，该如何将这个过程加入springboot的启动流程呢？\n​\t遇到这样的问题，大部分技术是这样设计的，设计若干个标准接口，对应程序中的所有标准过程。当你想干预某个过程时，实现接口就行了。例如spring技术中bean的生命周期管理就是采用标准接口进行的。\npublic class Abc implements InitializingBean, DisposableBean { public void destroy() throws Exception { //销毁操作 } public void afterPropertiesSet() throws Exception { //初始化操作 } } ​\tspringboot启动过程由于存在着大量的过程阶段，如果设计接口就要设计十余个标准接口，这样对开发者不友好，同时整体过程管理分散，十余个过程各自为政，管理难度大，过程过于松散。那springboot如何解决这个问题呢？它采用了一种最原始的设计模式来解决这个问题，这就是监听器模式，使用监听器来解决这个问题。\n​\tspringboot将自身的启动过程比喻成一个大的事件，该事件是由若干个小的事件组成的。例如：\norg.springframework.boot.context.event.ApplicationStartingEvent 应用启动事件，在应用运行但未进行任何处理时，将发送 ApplicationStartingEvent org.springframework.boot.context.event.ApplicationEnvironmentPreparedEvent 环境准备事件，当Environment被使用，且上下文创建之前，将发送 ApplicationEnvironmentPreparedEvent org.springframework.boot.context.event.ApplicationContextInitializedEvent 上下文初始化事件 org.springframework.boot.context.event.ApplicationPreparedEvent 应用准备事件，在开始刷新之前，bean定义被加载之后发送 ApplicationPreparedEvent org.springframework.context.event.ContextRefreshedEvent 上下文刷新事件 org.springframework.boot.context.event.ApplicationStartedEvent 应用启动完成事件，在上下文刷新之后且所有的应用和命令行运行器被调用之前发送 ApplicationStartedEvent org.springframework.boot.context.event.ApplicationReadyEvent 应用准备就绪事件，在应用程序和命令行运行器被调用之后，将发出 ApplicationReadyEvent，用于通知应用已经准备处理请求 org.springframework.context.event.ContextClosedEvent（上下文关闭事件，对应容器关闭） ​\t上述列出的仅仅是部分事件，当应用启动后走到某一个过程点时，监听器监听到某个事件触发，就会执行对应的事件。除了系统内置的事件处理，用户还可以根据需要自定义开发当前事件触发时要做的其他动作。\n//设定监听器，在应用启动开始事件时进行功能追加 public class MyListener implements ApplicationListener\u0026lt;ApplicationStartingEvent\u0026gt; { public void onApplicationEvent(ApplicationStartingEvent event) { //自定义事件处理逻辑 } } ​\t按照上述方案处理，用户就可以干预springboot启动过程的所有工作节点，设置自己的业务系统中独有的功能点。\n总结\nspringboot启动流程是先初始化容器需要的各种配置，并加载成各种对象，初始化容器时读取这些对象，创建容器 整体流程采用事件监听的机制进行过程控制，开发者可以根据需要自行扩展，添加对应的监听器绑定具体事件，就可以在事件触发位置执行开发者的业务代码 原理篇完结\r​\t原理篇到这里就要结束了，springboot2整套课程的基础篇、实用篇和原理篇就全部讲完了。至于后面的番外篇由于受B站视频上传总量不得超过200个视频的约束，番外篇的内容不会在当前课程中发布了，会重新定义一个课程继续发布，至于具体时间，暂时还无法给到各位小伙伴。\n​\t原理篇个人感觉略微有点偷懒。学习原理篇需要的前置铺垫知识较多，比如最后一节讲到启动流程时，看到 refresh 方法，我会想：现在在看这套课程的小伙伴是否真的理解这个过程？但如果把这些内容都展开讲，那要补充的知识就太多了，等于把 Spring 的很多知识重新并入本课程讲解，会出现喧宾夺主的现象。很纠结，( ´•︵•` )\n​\t课程做到这里，就要和各位小伙伴先说 goodbye 了。感谢各位小伙伴的支持，也欢迎大家持续关注黑马程序员出品的各种视频教程。黑马程序员的每位老师做课程都很认真，都是为了让致力于 IT 研发事业的小伙伴在学习路上少遇沟沟坎坎，顺利到达成功的彼岸。\n​\t番外篇，さようなら！ 안녕히 계십시오！แล้วเจอกัน！До свидания ！خداحافظ ！\n","date":"2026-04-11T00:00:00Z","permalink":"/p/spring-boot-%E7%B3%BB%E7%BB%9F%E8%AE%B2%E4%B9%89/","title":"Spring Boot 系统讲义"},{"content":"Spring Cloud Gateway 网关\r\u0026lt;!--网关--\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.cloud\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-cloud-starter-gateway\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!--nacos discovery--\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.alibaba.cloud\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-cloud-starter-alibaba-nacos-discovery\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; server: port: 8080 spring: application: name: gateway cloud: nacos: discovery: server-addr: 192.168.87.133:8848 gateway: routes: - id: item-service uri: lb://item-service predicates: - Path=/items/**,/search/** - id: cart uri: lb://cart-service predicates: - Path=/carts/** - id: user uri: lb://user-service predicates: - Path=/users/**,/addresses/** - id: trade uri: lb://trade-service predicates: - Path=/orders/** - id: pay uri: lb://pay-service predicates: - Path=/pay-orders/** default-filters: - AddRequestHeader=truth, blue 路由断言\r路由过滤\r登录实现\r自定义过滤器\rnettyRouting 优先级默认是最小的, 因此指定一个较大的数即可 package com.hmall.gateway.filters; import org.springframework.cloud.gateway.filter.GatewayFilterChain; import org.springframework.cloud.gateway.filter.GlobalFilter; import org.springframework.core.Ordered; import org.springframework.http.HttpHeaders; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.stereotype.Component; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; @Component public class MyGlobalFilter implements GlobalFilter , Ordered { @Override public Mono\u0026lt;Void\u0026gt; filter(ServerWebExchange exchange, GatewayFilterChain chain) { // TODO 模拟登录检验 ServerHttpRequest request = exchange.getRequest(); HttpHeaders headers = request.getHeaders(); System.out.println(headers); return chain.filter(exchange); } @Override public int getOrder() { return 0; } } 实现\r网关\r这里没有进行传递用户 package com.hmall.gateway.filters; import cn.hutool.core.text.AntPathMatcher; import com.hmall.common.exception.UnauthorizedException; import com.hmall.gateway.config.AuthProperties; import com.hmall.gateway.utils.JwtTool; import lombok.RequiredArgsConstructor; import org.springframework.cloud.gateway.filter.GatewayFilterChain; import org.springframework.cloud.gateway.filter.GlobalFilter; import org.springframework.core.Ordered; import org.springframework.http.HttpStatus; import org.springframework.http.server.RequestPath; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.stereotype.Component; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; import java.util.List; @RequiredArgsConstructor @Component public class AuthGlobalFilter implements GlobalFilter, Ordered { private final AuthProperties authProperties; private final JwtTool jwtTool; private final AntPathMatcher antPathMatcher = new AntPathMatcher(); @Override public Mono\u0026lt;Void\u0026gt; filter(ServerWebExchange exchange, GatewayFilterChain chain) { // 1. 获取request对象 ServerHttpRequest request = exchange.getRequest(); // 2. 判断是否需要做登录拦截 List\u0026lt;String\u0026gt; excludePaths = authProperties.getExcludePaths(); RequestPath path = request.getPath(); if(isExclude(path.toString(),excludePaths)){ // 放行 return chain.filter(exchange); } // 3. 获取token String token = null; List\u0026lt;String\u0026gt; tokens = request.getHeaders().get(\u0026#34;authorization\u0026#34;); if(tokens!=null\u0026amp;\u0026amp;!tokens.isEmpty()){ token = tokens.get(0); } Long userId = null; // 4. 校验解析token, 获取用户信息 try { userId = jwtTool.parseToken(token); } catch (UnauthorizedException e) { // 401 表示未登录 exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); Mono\u0026lt;Void\u0026gt; voidMono = exchange.getResponse().setComplete(); return voidMono; } // 5. 保存用户信息到请求头 System.out.println(\u0026#34;userId = \u0026#34;+userId); return chain.filter(exchange); } private boolean isExclude(String path, List\u0026lt;String\u0026gt; excludePaths) { for (String excludePatten : excludePaths) { if(antPathMatcher.match(excludePatten, path)){ return true; } } return false; } @Override public int getOrder() { return 0; } } 网关到微服务\r因为微服务都引用了 common 包, 因此将拦截器放到 common 中 public class UserInfoInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 1. 获取登录信息 String userInfo = request.getHeader(\u0026#34;user-info\u0026#34;); // 2. 判断是否获取了用户 if(StrUtil.isNotBlank(userInfo)){ // 3. 存入 threadLocal UserContext.setUser(Long.valueOf(userInfo)); } // 4. 放行 return HandlerInterceptor.super.preHandle(request, response, handler); } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { // 清理用户 UserContext.removeUser(); HandlerInterceptor.super.afterCompletion(request, response, handler, ex); } } @Configuration public class MvcConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new UserInfoInterceptor()); } } 因为 common 包不会被微服务包扫描, 因此 @Configuration 不会生效, 因此\n网关引入 配置类后报错, 因为网关不是 springBoot , 因此设置自定配置类的生效条件 微服务到微服务\r是由 openFeign 发起的 使 feign 配置类生效 ","date":"2026-04-11T00:00:00Z","permalink":"/p/spring-cloud-gateway-%E7%BD%91%E5%85%B3/","title":"Spring Cloud Gateway 网关"},{"content":"Spring 框架学习笔记\r1. Spring 概述\r是轻量级的开源 JavaEE 框架 可以解决企业应用开发的复杂性 两个核心：AOP/IOC AOP：切面编程，不修改源代码进行功能增强 IOC：控制反转，把创建对象过程交给Spring进行管理 Spring的特点 方便解耦，简化开发 AOP 编程支持 方便程序测试 方便整合其它框架 方便进行事务操作 降低API开发难度 2. IOC\r把对象的创建和对象之间的调用过程都交给Spring进行管理\n使用 IOC 的目的：解耦\n底层原理：xml解析、工厂模式、反射\nIOC（接口）\rIOC容器底层就是对象工厂\nSpring提供IOC容器实现的两种方式（两个接口）\nBeanFactory：IOC容器内置的基本实现，是Spring中内部使用的接口，不提供开发人员使用 ApplicationContext：BeanFactory接口的子接口，提供更多更强大的功能，一般是开发人员使用 两种方式的区别：BeanFactory在加载配置文件的时候不会创建对象，在获取对象才去创建对象 ApplicationContext在加载配置文件的时候就会把配置文件中的对象进行创建 ApplicationContext接口实现类\nIOC操作Bean管理\r什么是Bean管理 Spring创建对象 Spring注入属性 Bean管理操作有两种方式 基于xml配置文件 setter 有参构造 基于注解 IOC操作Bean管理（基于xml方式）\r基于xml方式创建对象\r在Spring配置文件中，使用bean标签，标签里面添加对应的属性 bean标签中常用的属性 id属性：给对象起一个唯一的标识 class属性：创建对象类的全路径（包类路径） 创建对象的时候默认执行无参构造 基于xml方式注入属性\rDI：依赖注入，就是注入属性\n第一种注入方式：使用setter\n创建类，定义属性和对应的setter方法\npublic class Book { private String bname; private String bauthor; public void setBname(String bname) { this.bname = bname; } public void setBauthor(String bauthor) { this.bauthor = bauthor; } } 在Spring配置文件中配置对象创建，配置属性注入\n第二种注入方式：使用有参构造\n创建有参构造方法\n向有参构造方法中注入属性\n通过名称空间进行注入（了解），底层是setter注入\n注入特殊值\n注入外部bean\n注入内部bean，级联关系\npackage top.lukeewin.spring5.bean; /** * @author Luke Ewin * @create 2022-10-10 21:20 */ public class Dept { private String dname; public void setDname(String dname) { this.dname = dname; } @Override public String toString() { return \u0026#34;Dept{\u0026#34; + \u0026#34;dname=\u0026#39;\u0026#34; + dname + \u0026#39;\\\u0026#39;\u0026#39; + \u0026#39;}\u0026#39;; } } package top.lukeewin.spring5.bean; /** * @author Luke Ewin * @create 2022-10-10 21:22 */ public class Emp { private String ename; private String gender; private Dept dept; public void setEname(String ename) { this.ename = ename; } public void setGender(String gender) { this.gender = gender; } public void setDept(Dept dept) { this.dept = dept; } public void add() { System.out.println(ename + \u0026#34;::\u0026#34; + gender + \u0026#34;::\u0026#34; + dept); } } \u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;beans xmlns=\u0026#34;http://www.springframework.org/schema/beans\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd\u0026#34;\u0026gt; \u0026lt;!-- 内部bean --\u0026gt; \u0026lt;bean id=\u0026#34;emp\u0026#34; class=\u0026#34;top.lukeewin.spring5.bean.Emp\u0026#34;\u0026gt; \u0026lt;property name=\u0026#34;ename\u0026#34; value=\u0026#34;lucy\u0026#34;/\u0026gt; \u0026lt;property name=\u0026#34;gender\u0026#34; value=\u0026#34;女\u0026#34;/\u0026gt; \u0026lt;!--设置对象--\u0026gt; \u0026lt;property name=\u0026#34;dept\u0026#34;\u0026gt; \u0026lt;bean id=\u0026#34;dept\u0026#34; class=\u0026#34;top.lukeewin.spring5.bean.Dept\u0026#34;\u0026gt; \u0026lt;property name=\u0026#34;dname\u0026#34; value=\u0026#34;安保部\u0026#34;/\u0026gt; \u0026lt;/bean\u0026gt; \u0026lt;/property\u0026gt; \u0026lt;/bean\u0026gt; \u0026lt;/beans\u0026gt; 级联的另外一种写法\n\u0026lt;!--级联赋值--\u0026gt; \u0026lt;bean id=\u0026#34;emp\u0026#34; class=\u0026#34;top.lukeewin.spring5.bean.Emp\u0026#34;\u0026gt; \u0026lt;property name=\u0026#34;ename\u0026#34; value=\u0026#34;lucy\u0026#34;/\u0026gt; \u0026lt;property name=\u0026#34;gender\u0026#34; value=\u0026#34;女\u0026#34;/\u0026gt; \u0026lt;property name=\u0026#34;dept\u0026#34; ref=\u0026#34;dept\u0026#34;/\u0026gt; \u0026lt;/bean\u0026gt; \u0026lt;bean id=\u0026#34;dept\u0026#34; class=\u0026#34;top.lukeewin.spring5.bean.Dept\u0026#34;\u0026gt; \u0026lt;property name=\u0026#34;dname\u0026#34; value=\u0026#34;财务部\u0026#34;/\u0026gt; \u0026lt;/bean\u0026gt; 级联的第三种方法\nEmp类需要生成Dept的getter方法\n\u0026lt;!--级联赋值--\u0026gt; \u0026lt;bean id=\u0026#34;emp\u0026#34; class=\u0026#34;top.lukeewin.spring5.bean.Emp\u0026#34;\u0026gt; \u0026lt;property name=\u0026#34;ename\u0026#34; value=\u0026#34;lucy\u0026#34;/\u0026gt; \u0026lt;property name=\u0026#34;gender\u0026#34; value=\u0026#34;女\u0026#34;/\u0026gt; \u0026lt;property name=\u0026#34;dept\u0026#34; ref=\u0026#34;dept\u0026#34;/\u0026gt; \u0026lt;property name=\u0026#34;dept.dname\u0026#34; value=\u0026#34;技术部\u0026#34;/\u0026gt; \u0026lt;/bean\u0026gt; \u0026lt;bean id=\u0026#34;dept\u0026#34; class=\u0026#34;top.lukeewin.spring5.bean.Dept\u0026#34;\u0026gt; \u0026lt;property name=\u0026#34;dname\u0026#34; value=\u0026#34;财务部\u0026#34;/\u0026gt; \u0026lt;/bean\u0026gt; 注入集合类型属性\r数组\r\u0026lt;property name=\u0026#34;courses\u0026#34;\u0026gt; \u0026lt;array\u0026gt; \u0026lt;value\u0026gt;Java\u0026lt;/value\u0026gt; \u0026lt;value\u0026gt;MySQL\u0026lt;/value\u0026gt; \u0026lt;/array\u0026gt; \u0026lt;/property\u0026gt; list集合\r\u0026lt;property name=\u0026#34;list\u0026#34;\u0026gt; \u0026lt;list\u0026gt; \u0026lt;value\u0026gt;张三\u0026lt;/value\u0026gt; \u0026lt;value\u0026gt;李四\u0026lt;/value\u0026gt; \u0026lt;/list\u0026gt; \u0026lt;/property\u0026gt; set集合\r\u0026lt;property name=\u0026#34;sets\u0026#34;\u0026gt; \u0026lt;set\u0026gt; \u0026lt;value\u0026gt;MySQL\u0026lt;/value\u0026gt; \u0026lt;value\u0026gt;Redis\u0026lt;/value\u0026gt; \u0026lt;/set\u0026gt; \u0026lt;/property\u0026gt; map集合\r\u0026lt;property name=\u0026#34;maps\u0026#34;\u0026gt; \u0026lt;map\u0026gt; \u0026lt;entry key=\u0026#34;JAVA\u0026#34; value=\u0026#34;java\u0026#34;/\u0026gt; \u0026lt;entry key=\u0026#34;PHP\u0026#34; value=\u0026#34;php\u0026#34;/\u0026gt; \u0026lt;/map\u0026gt; \u0026lt;/property\u0026gt; 向集合中注入对象属性\r把集合注入部分提取出来\n在 spring 配置文件中引入名称空间 util\n使用 util 标签完成 list 集合注入提取\n两种bean\r普通bean和工厂bean\n工厂bean\n\u0026lt;bean id=\u0026#34;myBean\u0026#34; class=\u0026#34;top.lukeewin.spring5.factorybean.MyBean\u0026#34;\u0026gt;\u0026lt;/bean\u0026gt; public class MyBean implements FactoryBean\u0026lt;Course\u0026gt; { // 定义返回bean @Override public Course getObject() throws Exception { Course course = new Course(); course.setCname(\u0026#34;abc\u0026#34;); return course; } @Override public Class\u0026lt;?\u0026gt; getObjectType() { return null; } @Override public boolean isSingleton() { return false; } } @Test public void test3() { ApplicationContext context = new ClassPathXmlApplicationContext(\u0026#34;bean3.xml\u0026#34;); Course course = context.getBean(\u0026#34;myBean\u0026#34;, Course.class); System.out.println(course); } Bean的作用域\rBean 的作用域包含：singleton、prototype、session、application\n默认值为 singleton，表示单实例对象\nprototype 表示多实例对象\nBean的生命周期\r从对象的创建到对象的销毁的过程\nBean的生命周期：\n通过构造器创建bean实例（调用无参构造方法） 为bean的属性设置值和对其它bean的引用（调用setter方法） 把bean实例传递到bean的后置处理器的方法postProcessBeforeInitialization 调用bean的初始化方法（需要在配置文件中进行配置初始化方法） 把bean实例传递到bean后置处理器的方法postProcessAfterInitialization bean可以使用了（获取对象） 当容器关闭时，调用bean的销毁方法（需要在配置文件中配置销毁方法） public class MyBeanPost implements BeanPostProcessor { @Override public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException { System.out.println(\u0026#34;在初始化之前执行的方法\u0026#34;); return bean; } @Override public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { System.out.println(\u0026#34;在初始化之后执行的方法\u0026#34;); return bean; } } 需要在 bean.xml 中进行配置。\n运行结果：\nIOC操作Bean管理（xml自动装配）\r什么是自动装配？\n根据指定的装配规则（属性名称或者属性类型），Spring自动将匹配的属性值进行注入\n根据属性名称自动注入\n\u0026lt;bean id=\u0026#34;emp\u0026#34; class=\u0026#34;com.atguigu.spring5.autowire.Emp\u0026#34; autowire=\u0026#34;byName\u0026#34;\u0026gt; \u0026lt;!--\u0026lt;property name=\u0026#34;dept\u0026#34; ref=\u0026#34;dept\u0026#34;\u0026gt;\u0026lt;/property\u0026gt;--\u0026gt; \u0026lt;/bean\u0026gt; \u0026lt;bean id=\u0026#34;dept\u0026#34; class=\u0026#34;com.atguigu.spring5.autowire.Dept\u0026#34;\u0026gt;\u0026lt;/bean\u0026gt; 根据属性类型自动注入\n\u0026lt;bean id=\u0026#34;emp\u0026#34; class=\u0026#34;com.atguigu.spring5.autowire.Emp\u0026#34; autowire=\u0026#34;byType\u0026#34;\u0026gt; \u0026lt;!--\u0026lt;property name=\u0026#34;dept\u0026#34; ref=\u0026#34;dept\u0026#34;\u0026gt;\u0026lt;/property\u0026gt;--\u0026gt; \u0026lt;/bean\u0026gt; \u0026lt;bean id=\u0026#34;dept\u0026#34; class=\u0026#34;com.atguigu.spring5.autowire.Dept\u0026#34;\u0026gt;\u0026lt;/bean\u0026gt; 总结：\nbean标签中的autowire=“byName”实现按照属性名称进行自动装配，注入值 bean 的 id 值和类属性名称一样\nbean标签中的autowire=“byType”实现按照属性类型进行自动装配\nIOC操作Bean管理（外部属性文件）\r直接配置数据源\r\u0026lt;!--直接配置连接池--\u0026gt; \u0026lt;bean id=\u0026#34;dataSource\u0026#34; class=\u0026#34;com.alibaba.druid.pool.DruidDataSource\u0026#34;\u0026gt; \u0026lt;property name=\u0026#34;driverClassName\u0026#34; value=\u0026#34;com.mysql.jdbc.Driver\u0026#34;\u0026gt;\u0026lt;/property\u0026gt; \u0026lt;property name=\u0026#34;url\u0026#34; value=\u0026#34;jdbc:mysql://localhost:3306/userDb\u0026#34;\u0026gt;\u0026lt;/property\u0026gt; \u0026lt;property name=\u0026#34;username\u0026#34; value=\u0026#34;root\u0026#34;\u0026gt;\u0026lt;/property\u0026gt; \u0026lt;property name=\u0026#34;password\u0026#34; value=\u0026#34;root\u0026#34;\u0026gt;\u0026lt;/property\u0026gt; \u0026lt;/bean\u0026gt; 引入外部配置数据源\r需要先引入druid依赖\n然后定义外部数据源\n最后把jdbc.properties文件引入到bean.xml配置文件中，需要先引入context名称空间\n\u0026lt;!--引入外部属性文件--\u0026gt; \u0026lt;context:property-placeholder location=\u0026#34;classpath:jdbc.properties\u0026#34;/\u0026gt; \u0026lt;!--配置连接池--\u0026gt; \u0026lt;bean id=\u0026#34;dataSource\u0026#34; class=\u0026#34;com.alibaba.druid.pool.DruidDataSource\u0026#34;\u0026gt; \u0026lt;property name=\u0026#34;driverClassName\u0026#34; value=\u0026#34;${prop.driverClass}\u0026#34;\u0026gt;\u0026lt;/property\u0026gt; \u0026lt;property name=\u0026#34;url\u0026#34; value=\u0026#34;${prop.url}\u0026#34;\u0026gt;\u0026lt;/property\u0026gt; \u0026lt;property name=\u0026#34;username\u0026#34; value=\u0026#34;${prop.userName}\u0026#34;\u0026gt;\u0026lt;/property\u0026gt; \u0026lt;property name=\u0026#34;password\u0026#34; value=\u0026#34;${prop.password}\u0026#34;\u0026gt;\u0026lt;/property\u0026gt;# \u0026lt;/bean\u0026gt; IOC 操作 Bean 管理(基于注解方式)\r什么是注解 ？\n（1）注解是代码特殊标记，格式：@注解名称(属性名称=属性值, 属性名称=属性值…)\n（2）使用注解，注解作用在类上面，方法上面，属性上面\n（3）使用注解目的：简化 xml 配置\nSpring 针对 Bean 管理中创建对象提供注解\n（1）@Component\n（2）@Service\n（3）@Controller\n（4）@Repository\n上面四个注解功能是一样的，都可以用来创建 bean 实例\n基于注解方式实现对象的创建过程\n引入aop依赖\n开启组件扫描\n\u0026lt;context:component-scan base-package=\u0026#34;top.lukeewin\u0026#34;\u0026gt;\u0026lt;/context:component-scan\u0026gt; 如果需要扫描多个包，可以使用逗号分隔开\n创建类，在类上面添加创建对象注解\n//在注解里面 value 属性值可以省略不写， //默认值是类名称，首字母小写 //UserService -- userService @Component(value = \u0026#34;userService\u0026#34;) //\u0026lt;bean id=\u0026#34;userService\u0026#34; class=\u0026#34;..\u0026#34;/\u0026gt; public class UserService { public void add() { System.out.println(\u0026#34;service add.......\u0026#34;); } } 开启组件扫描的细节\nuse-default-filters=“false” 表示现在不使用默认 filter，自己配置 filter context:include-filter ，设置扫描哪些内容\n\u0026lt;context:component-scan base-package=\u0026#34;top.lukeewin\u0026#34; use-defaultfilters=\u0026#34;false\u0026#34;\u0026gt; \u0026lt;context:include-filter type=\u0026#34;annotation\u0026#34; expression=\u0026#34;org.springframework.stereotype.Controller\u0026#34;/\u0026gt; \u0026lt;/context:component-scan\u0026gt; 下面配置扫描包所有内容 context:exclude-filter： 设置哪些内容不进行扫描\n\u0026lt;context:component-scan base-package=\u0026#34;top.lukeewin\u0026#34;\u0026gt; \u0026lt;context:exclude-filter type=\u0026#34;annotation\u0026#34; expression=\u0026#34;org.springframework.stereotype.Controller\u0026#34;/\u0026gt; \u0026lt;/context:component-scan\u0026gt; 基于注解方式实现属性注入\n（1）@Autowired：根据属性类型进行自动装配\n第一步 把 service 和 dao 对象创建，在 service 和 dao 类添加创建对象注解\n第二步 在 service 注入 dao 对象，在 service 类添加 dao 类型属性，在属性上面使用注解\n@Service public class UserService { //定义 dao 类型属性 //不需要添加 set 方法 //添加注入属性注解 @Autowired private UserDao userDao; public void add() { System.out.println(\u0026#34;service add.......\u0026#34;); userDao.add(); } } （2）@Qualifier：根据名称进行注入 这个@Qualifier 注解的使用，和上面@Autowired 一起使用(有多个类实现了一个接口, 这是autowire指定类型已经不行了, 因此需要指定名称)\n@Autowired //根据类型进行注入 @Qualifier(value = \u0026#34;userDaoImpl1\u0026#34;) //根据名称进行注入 private UserDao userDao; （3）@Resource(jdk自带的, 不是spring框架)：可以根据类型注入，可以根据名称注入 //@Resource //根据类型进行注入 @Resource(name = \u0026#34;userDaoImpl1\u0026#34;) //根据名称进行注入 private UserDao userDao; （4）@Value：注入普通类型属性\n@Value(value = \u0026#34;abc\u0026#34;) private String name; 完全注解开发\n创建配置类，替代 xml 配置文件\n@Configuration //作为配置类，替代 xml 配置文件 // \u0026lt;context:component-scan base-package=\u0026#34;top.lukeewin\u0026#34;\u0026gt;\u0026lt;/context:component-scan\u0026gt; @ComponentScan(basePackages = {\u0026#34;top.lukeewin\u0026#34;}) public class SpringConfig { } 编写测试类\n注意：这里创建的是==AnnotationConfigApplicationContext==对象\n@Test public void testService2() { //加载配置类 ApplicationContext context = new AnnotationConfigApplicationContext(SpringConfig.class); UserService userService = context.getBean(\u0026#34;userService\u0026#34;, UserService.class); System.out.println(userService); userService.add(); } 3. AOP\r什么是AOP\r面向切面编程，利用AOP可以对业务逻辑的各个部分进行隔离，从而降低各个部分之间的耦合度，提高了程序的可用性，同时也能提高开发效率\n不用修改源代码，在主干程序中添加新的功能\n使用登录例子说明AOP\nAOP底层原理\rAOP底层使用动态代理，有两种动态代理：JDK动态代理和CGLIB动态代理\n对于被增强类有接口的情况下，可以使用JDK动态代理\n对于被增强类没有接口的情况下，使用CGLIB动态代理\nJDK动态代理和CGLIB动态代理\rJDK动态代理需要要求被代理类至少实现一个接口。同时代理类还需要实现InvocationHandler接口，重写invoke()，使用Proxy.newProxyInstance()产生代理对象\nCGLIB动态代理要求被代理的方法不能被final修饰\n下面是JDK动态代理代码：\npackage top.lukeewin.spring5.proxydemo; import java.lang.reflect.InvocationHandler; import java.lang.reflect.Method; import java.lang.reflect.Proxy; // 代理类必须实现InvocationHandler public class JDKProxy implements InvocationHandler { private Object obj; // 通过有参构造把真实的对象注进来 public JDKProxy(Object obj) { this.obj = obj; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { System.out.println(\u0026#34;洗手\u0026#34;); Object res = method.invoke(obj); System.out.println(\u0026#34;放好餐具\u0026#34;); return res; } } interface Person { void eat(); } // 被代理类 class Student implements Person { // 要被增强的方法 @Override public void eat() { System.out.println(\u0026#34;学生吃饭\u0026#34;); } } class TestProxy { public static void main(String[] args) { Student student = new Student(); Person proxyInstance = (Person) Proxy.newProxyInstance(Student.class.getClassLoader(), Student.class.getInterfaces(), new JDKProxy(student)); proxyInstance.eat(); } } CGLIB动态代理：\npackage top.lukeewin.spring5.proxydemo; import org.springframework.cglib.proxy.Enhancer; import org.springframework.cglib.proxy.MethodInterceptor; import org.springframework.cglib.proxy.MethodProxy; import java.lang.reflect.Method; public class CGLIBProxy implements MethodInterceptor { private Object obj; // 通过有参构造把真实的对象注进来 public CGLIBProxy(Object obj) { this.obj = obj; } @Override public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable { System.out.println(\u0026#34;猫吃食物之前\u0026#34;); Object res = method.invoke(obj, objects); System.out.println(\u0026#34;猫吃食物之后\u0026#34;); return res; } } class Cat { public void eat(String food) { System.out.println(\u0026#34;猫吃\u0026#34; + food); } } class TestCGLIBDemo { public static void main(String[] args) { Cat cat = new Cat(); // 创建被增强类对象 CGLIBProxy cglibProxy = new CGLIBProxy(cat); // 创建增强类对象 Cat c = (Cat) Enhancer.create(cat.getClass(), cglibProxy); c.eat(\u0026#34;老鼠\u0026#34;); } } AOP术语\r连接点：类里面可以被增强的方法（并不是所有方法都可以被增强，例如使用CGLIB动态代理时，不能增强final方法） 切入点：实际上要被增强的方法 通知（增强）：实际增强的逻辑部分 通知分为以下几类： 前置通知（before） 后置通知（after） 环绕通知（aroud） 异常通知（afterThrowing） 最终通知（返回通知）（afterReturing） 需要注意不同通知的执行顺序。先执行环绕通知，然后是前置通知。没有异常时的执行顺序：环绕之前，前置通知，被增强方法，环绕之后，后置通知，最终通知。有异常情况：环绕之前，前置通知，后置通知，异常通知。无论发没发生异常都会执行后置通知 切面：把通知应用到切点的过程 通知（基于全注解）\r创建配置类\n@Configuration @EnableAspectJAutoProxy(proxyTargetClass = true) // 开启AspectJ @ComponentScan(basePackages = \u0026#34;top.lukeewin.spring5.aop\u0026#34;) public class SpringConfig { } 创建被增强类\n@Component public class User { public void add() { System.out.println(\u0026#34;添加\u0026#34;); } } 创建增强类\n@Component @Aspect public class AspectJProxy { // 权限(可以省略), 返回值类型(*表示任意), 类路径, 方法, 参数(..表示任意多个) @Before(value = \u0026#34;execution(* top.lukeewin.spring5.aop.User.add(..))\u0026#34;) public void before() { System.out.println(\u0026#34;before......\u0026#34;); } @After(value = \u0026#34;execution(* top.lukeewin.spring5.aop.User.add(..))\u0026#34;) public void after() { System.out.println(\u0026#34;after......\u0026#34;); } @Around(value = \u0026#34;execution(* top.lukeewin.spring5.aop.User.add(..))\u0026#34;) public void around(ProceedingJoinPoint proceedingJoinPoint) throws Throwable { System.out.println(\u0026#34;around before......\u0026#34;); proceedingJoinPoint.proceed(); System.out.println(\u0026#34;around after......\u0026#34;); } @AfterThrowing(value = \u0026#34;execution(* top.lukeewin.spring5.aop.User.add(..))\u0026#34;) public void afterThrowing() { System.out.println(\u0026#34;afterThrowing......\u0026#34;); } @AfterReturning(value = \u0026#34;execution(* top.lukeewin.spring5.aop.User.add(..))\u0026#34;) public void afterReturning() { System.out.println(\u0026#34;afterReturning......\u0026#34;); } } 测试类\npublic class TestAspectJ { public static void main(String[] args) { ApplicationContext context = new AnnotationConfigApplicationContext(SpringConfig.class); User user = (User) context.getBean(\u0026#34;user\u0026#34;); user.add(); } } 运行结果\n[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2Vxnlk72-1665737854085)(Spring学习笔记.assets/image-20221012184217805.png)]\n如果被增强的方法有异常，则结果如下：\n结论：如果没有异常时，执行的顺序为：环绕通知，前置通知，被增强方法，后置通知，后置返回通知\n如果有异常，则执行顺序为：环绕通知，前置通知，后置通知，异常通知\n通知（基于配置文件）\r如果不是全注解的方式，配合xml配置文件的方式，则配置文件如下：\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;beans xmlns=\u0026#34;http://www.springframework.org/schema/beans\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xmlns:context=\u0026#34;http://www.springframework.org/schema/context\u0026#34; xmlns:aop=\u0026#34;http://www.springframework.org/schema/aop\u0026#34; xsi:schemaLocation=\u0026#34;http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd http://www.springframework.org/schema/aop http://www.springframework.org/schema/aop/spring-aop.xsd\u0026#34;\u0026gt; \u0026lt;!-- 开启组件扫描 --\u0026gt; \u0026lt;context:component-scan base-package=\u0026#34;top.lukeewin.spring5.aop\u0026#34;/\u0026gt; \u0026lt;!-- 开启Aspectj注解 --\u0026gt; \u0026lt;aop:aspectj-autoproxy proxy-target-class=\u0026#34;true\u0026#34;/\u0026gt; \u0026lt;/beans\u0026gt; 出测试类外，其它的类不变，测试类如下：\npublic class TestAspectJDemo { public static void main(String[] args) { ApplicationContext context = new ClassPathXmlApplicationContext(\u0026#34;bean4.xml\u0026#34;); User user = (User) context.getBean(\u0026#34;user\u0026#34;); user.add(); } } 如果是完全基于xml配置文件的方式，则如下：\n\u0026lt;!--配置 aop 增强--\u0026gt; \u0026lt;aop:config\u0026gt; \u0026lt;!--切入点--\u0026gt; \u0026lt;aop:pointcut id=\u0026#34;p\u0026#34; expression=\u0026#34;execution(* com.atguigu.spring5.aopxml.Book.buy(..))\u0026#34;/\u0026gt; \u0026lt;!--配置切面--\u0026gt; \u0026lt;aop:aspect ref=\u0026#34;bookProxy\u0026#34;\u0026gt; \u0026lt;!--增强作用在具体的方法上--\u0026gt; \u0026lt;aop:before method=\u0026#34;before\u0026#34; pointcut-ref=\u0026#34;p\u0026#34;/\u0026gt; \u0026lt;/aop:aspect\u0026gt; \u0026lt;/aop:config\u0026gt; 抽取相同的切入点\n// 抽取相同的切入点 // 切点表达式格式：访问修饰符 返回值 包名.类名.方法名(参数列表) // 访问修饰符可以省略 @Pointcut(\u0026#34;execution(* top.lukeewin.spring5.aop.User.add(..))\u0026#34;) public void pointCut() { } // 引入切点表达式 @Before(value = \u0026#34;pointCut()\u0026#34;) public void before() { System.out.println(\u0026#34;before......\u0026#34;); } 如果有多个类对同一个类中的方法增强，则需要设定优先级，使用@Order(数值)来设置，数值越小，优先级越高，就越先执行。\n4. JdbcTemplate\rJdbcTemplate准备工作\r先要引入相关的依赖\n编写jdbc.properties配置文件\njdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/test?serverTimezone=Asia/Shanghai jdbc.username=root jdbc.password=XRXxrx123 编写bean.xml配置文件\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;beans xmlns=\u0026#34;http://www.springframework.org/schema/beans\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xmlns:context=\u0026#34;http://www.springframework.org/schema/context\u0026#34; xsi:schemaLocation=\u0026#34;http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd\u0026#34;\u0026gt; \u0026lt;!-- 开启组件扫描 --\u0026gt; \u0026lt;context:component-scan base-package=\u0026#34;top.lukeewin.spring5\u0026#34;/\u0026gt; \u0026lt;!-- 读取jdbc.properties文件 --\u0026gt; \u0026lt;context:property-placeholder location=\u0026#34;jdbc.properties\u0026#34;/\u0026gt; \u0026lt;!-- 生成数据源对象 --\u0026gt; \u0026lt;bean id=\u0026#34;dataSource\u0026#34; class=\u0026#34;com.alibaba.druid.pool.DruidDataSource\u0026#34; destroy-method=\u0026#34;close\u0026#34;\u0026gt; \u0026lt;property name=\u0026#34;driverClassName\u0026#34; value=\u0026#34;${jdbc.driver}\u0026#34;/\u0026gt; \u0026lt;property name=\u0026#34;url\u0026#34; value=\u0026#34;${jdbc.url}\u0026#34;/\u0026gt; \u0026lt;property name=\u0026#34;username\u0026#34; value=\u0026#34;${jdbc.username}\u0026#34;/\u0026gt; \u0026lt;property name=\u0026#34;password\u0026#34; value=\u0026#34;${jdbc.password}\u0026#34;/\u0026gt; \u0026lt;/bean\u0026gt; \u0026lt;!-- 生成JdbcTemplate对象 --\u0026gt; \u0026lt;bean id=\u0026#34;jdbcTemplate\u0026#34; class=\u0026#34;org.springframework.jdbc.core.JdbcTemplate\u0026#34;\u0026gt; \u0026lt;!-- 向jdbcTemplate对象中的dataSource属性注入值 --\u0026gt; \u0026lt;property name=\u0026#34;dataSource\u0026#34; ref=\u0026#34;dataSource\u0026#34;/\u0026gt; \u0026lt;/bean\u0026gt; \u0026lt;/beans\u0026gt; JdbcTemplate添加操作\r编写实体类\npublic class Book { private Integer id; private String name; private float price; private Date time; public Book() { } public Book(Integer id, String name, float price, Date time) { this.id = id; this.name = name; this.price = price; this.time = time; } public Integer getId() { return id; } public void setId(Integer id) { this.id = id; } public String getName() { return name; } public void setName(String name) { this.name = name; } public float getPrice() { return price; } public void setPrice(float price) { this.price = price; } public Date getTime() { return time; } public void setTime(Date time) { this.time = time; } } 编写Dao接口\npublic interface BookDao { void add(Book book); } 编写Dao接口的实现类\n@Repository public class BookDaoImpl implements BookDao { // 注入jdbcTemplate对象 @Autowired private JdbcTemplate jdbcTemplate; @Override public void add(Book book) { String sql = \u0026#34;insert into books(name, price, time) values(?,?,?)\u0026#34;; Object[] args = {book.getName(), book.getPrice(), book.getTime()}; int update = jdbcTemplate.update(sql, args); System.out.println(update); } } 编写Service类，这里省略了编写Service接口\n@Service public class BookService { // 注入Dao @Autowired private BookDao bookDao; public void add(Book book) { // 调用Dao中的方法 bookDao.add(book); } } 编写测试类\npublic class JdbcTemplateDemo { public static void main(String[] args) { ApplicationContext context = new ClassPathXmlApplicationContext(\u0026#34;bean5.xml\u0026#34;); // 解析xml配置文件 BookService bookService = context.getBean(\u0026#34;bookService\u0026#34;, BookService.class); // 从IOC容器中获取bookService对象 Book book = new Book(); book.setName(\u0026#34;PHP\u0026#34;); book.setPrice(25.5f); book.setTime(new Date()); bookService.add(book); } } JdbcTemplate修改操作\r@Override public void updateBook(Book book) { String sql = \u0026#34;update books set name = ?, price = ? where id = ?\u0026#34;; Object[] args = {book.getName(), book.getPrice(),book.getId()}; int update = jdbcTemplate.update(sql, args); System.out.println(update); } JdbcTemplate删除操作\r@Override public void deleteBook(int id) { String sql = \u0026#34;delete from books where id = ?\u0026#34;; int update = jdbcTemplate.update(sql, id); System.out.println(update); } JdbcTemplate查询操作\r查询返回某个数\r@Override public int selectCount() { String sql = \u0026#34;select count(*) from books\u0026#34;; Integer count = jdbcTemplate.queryForObject(sql, Integer.class); return count; } 返回单个对象\r@Override public Book findBookById(int id) { String sql = \u0026#34;select * from books where id = ?\u0026#34;; Book book = jdbcTemplate.queryForObject(sql, new BeanPropertyRowMapper\u0026lt;Book\u0026gt;(Book.class), id); return book; } 总结：添加、删除、修改都是调用jdbcTemplate.update()方法，查询则调用jdbcTemplate.queryForObject()方法\n查询中第二个参数填RowMapper接口的实现类，这里写BeanPropertyRowMapper\u0026lt;\u0026gt;()，泛型填写要封装到哪个类，参数填写要封装类的字节码文件\n返回一个集合\r@Override public List\u0026lt;Book\u0026gt; findAllBooks() { String sql = \u0026#34;select * from books\u0026#34;; List\u0026lt;Book\u0026gt; bookList = jdbcTemplate.query(sql, new BeanPropertyRowMapper\u0026lt;Book\u0026gt;(Book.class)); return bookList; } JdbcTemplate批量操作\r批量添加\r批量删除\r批量修改\r5. 事务\r事务是数据库操作的最基本单元。从逻辑上看，它是一组操作：要么全部成功，要么全部失败。\n事务的四大特性：ACID\n原子性 一致性 隔离性 持久性 事务添加到 JavaEE 三层结构里面 Service 层（业务逻辑层）\n在Spring中有两种方式进行事务操作：编程式事务管理和声明式事务管理（使用）\n声明式事务管理分为：基于xml配置文件方式和基于注解方式（开发中常用）\n基于注解方式的声明式事务管理\r在 Spring 进行声明式事务管理，底层使用 AOP 原理\n编写配置文件\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;beans xmlns=\u0026#34;http://www.springframework.org/schema/beans\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xmlns:context=\u0026#34;http://www.springframework.org/schema/context\u0026#34; xmlns:tx=\u0026#34;http://www.springframework.org/schema/tx\u0026#34; xmlns:aop=\u0026#34;http://www.springframework.org/schema/aop\u0026#34; xsi:schemaLocation=\u0026#34;http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd http://www.springframework.org/schema/tx http://www.springframework.org/schema/tx/spring-tx.xsd http://www.springframework.org/schema/aop http://www.springframework.org/schema/aop/spring-aop.xsd\u0026#34;\u0026gt; \u0026lt;!-- 开启组件扫描 --\u0026gt; \u0026lt;context:component-scan base-package=\u0026#34;top.lukeewin.spring5\u0026#34;/\u0026gt; \u0026lt;!-- 读取jdbc.properties文件 --\u0026gt; \u0026lt;context:property-placeholder location=\u0026#34;jdbc.properties\u0026#34;/\u0026gt; \u0026lt;!-- 生成数据源对象 --\u0026gt; \u0026lt;bean id=\u0026#34;dataSource\u0026#34; class=\u0026#34;com.alibaba.druid.pool.DruidDataSource\u0026#34; destroy-method=\u0026#34;close\u0026#34;\u0026gt; \u0026lt;property name=\u0026#34;driverClassName\u0026#34; value=\u0026#34;${jdbc.driver}\u0026#34;/\u0026gt; \u0026lt;property name=\u0026#34;url\u0026#34; value=\u0026#34;${jdbc.url}\u0026#34;/\u0026gt; \u0026lt;property name=\u0026#34;username\u0026#34; value=\u0026#34;${jdbc.username}\u0026#34;/\u0026gt; \u0026lt;property name=\u0026#34;password\u0026#34; value=\u0026#34;${jdbc.password}\u0026#34;/\u0026gt; \u0026lt;/bean\u0026gt; \u0026lt;!-- 生成JdbcTemplate对象 --\u0026gt; \u0026lt;bean id=\u0026#34;jdbcTemplate\u0026#34; class=\u0026#34;org.springframework.jdbc.core.JdbcTemplate\u0026#34;\u0026gt; \u0026lt;!-- 向jdbcTemplate对象中的dataSource属性注入值 --\u0026gt; \u0026lt;property name=\u0026#34;dataSource\u0026#34; ref=\u0026#34;dataSource\u0026#34;/\u0026gt; \u0026lt;/bean\u0026gt; \u0026lt;!-- 创建事务管理器对象 --\u0026gt; \u0026lt;bean id=\u0026#34;transactionManager\u0026#34; class=\u0026#34;org.springframework.jdbc.datasource.DataSourceTransactionManager\u0026#34;\u0026gt; \u0026lt;!-- 注入数据源 --\u0026gt; \u0026lt;property name=\u0026#34;dataSource\u0026#34; ref=\u0026#34;dataSource\u0026#34;/\u0026gt; \u0026lt;/bean\u0026gt; \u0026lt;!-- 开启事务注解 --\u0026gt; \u0026lt;tx:annotation-driven transaction-manager=\u0026#34;transactionManager\u0026#34;/\u0026gt; \u0026lt;/beans\u0026gt; 核心部分\n\u0026lt;!-- 创建事务管理器对象 --\u0026gt; \u0026lt;bean id=\u0026#34;transactionManager\u0026#34; class=\u0026#34;org.springframework.jdbc.datasource.DataSourceTransactionManager\u0026#34;\u0026gt; \u0026lt;!-- 注入数据源 --\u0026gt; \u0026lt;property name=\u0026#34;dataSource\u0026#34; ref=\u0026#34;dataSource\u0026#34;/\u0026gt; \u0026lt;/bean\u0026gt; \u0026lt;!-- 开启事务注解 --\u0026gt; \u0026lt;tx:annotation-driven transaction-manager=\u0026#34;transactionManager\u0026#34;/\u0026gt; 在 Service 中添加 @Transactional 注解，可以加在类上，也可以加在方法上。\n// 添加 @Transactional public void add(Book book) { // 调用Dao中的方法 bookDao.add(book); int a = 10 / 0; } 事务传播行为（重点理解）\rSpring在TransactionDefinition接口中规定了7种类型的事务传播行为\n事务传播行为是Spring框架独有的事务增强特性\nrequired / supports / mandatory / requires_new / not supported / never / nested\nrequired需要，没有新建，有加入，Spring中的事务默认为required supports支持，有则加入，没有就不管了，非事务运行 mandatory强制性，有则加入，没有异常 requires_new需要新的，不管有没有，直接创建新事务 not supported不支持事务，存在就挂起 never不支持事务，存在就异常 nested存在就在嵌套的执行，没有就找是否存在外面的事务，有则加入，没有则新建 参考文档\n事务隔离级别（重点理解）\r事务的四大特性分别是：原子性、一致性、隔离性、持久性\n由四大特性引出的三个问题：脏读、不可重复读、幻读\n脏读（读取未提交数据）\n==A事务读取B事务尚未提交的数据，此时如果B事务发生错误并执行回滚操作，那么A事务读取到的数据就是脏数据。==就好像原本的数据比较干净、纯粹，此时由于B事务更改了它，这个数据变得不再纯粹。这个时候A事务立即读取了这个脏数据，但事务B良心发现，又用回滚把数据恢复成原来干净、纯粹的样子，而事务A却什么都不知道，最终结果就是事务A读取了此次的脏数据，称为脏读。\n不可重复读（前后多次读取，数据内容不一致）\n事务A在执行读取操作，由整个事务A比较大，前后读取同一条数据需要经历很长的时间 。而在事务A第一次读取数据，比如此时读取了小明的年龄为20岁，事务B执行更改操作，将小明的年龄更改为30岁，此时事务A第二次读取到小明的年龄时，发现其年龄是30岁，和之前的数据不一样了，也就是数据不重复了，系统不可以读取到重复的数据，成为不可重复读。\n幻读（前后多次读取，数据总量不一致）\n事务A在执行读取操作，需要两次统计数据的总量，前一次查询数据总量后，此时事务B执行了新增数据的操作并提交后，这个时候事务A读取的数据总量和之前统计的不一样，就像产生了幻觉一样，==平白无故的多了几条数据==，成为幻读。\n四种隔离级别\rRead uncommitted 读未提交（一个事务可以读取另一个未提交事务的数据）有脏读问题 Read committed 读已提交（一个事务要等另一个事务提交后才能读取数据）解决脏读问题 Oracle默认级别 有不可重复读问题 Repeatable read 重复读（在开始读取数据（事务开启）时，不再允许修改操作）解决不可重复读 MySQL默认级别 有幻读问题 Serializable 序列化 解决脏读、不可重复读、幻读情况 参考资料\n完全注解声明式事务管理\r配置类\n@Configuration //配置类 @ComponentScan(basePackages = \u0026#34;top.lukeewin\u0026#34;) //组件扫描 @EnableTransactionManagement //开启事务 public class TxConfig { //创建数据库连接池 @Bean public DruidDataSource getDruidDataSource() { DruidDataSource dataSource = new DruidDataSource(); dataSource.setDriverClassName(\u0026#34;com.mysql.cj.jdbc.Driver\u0026#34;); dataSource.setUrl(\u0026#34;jdbc:mysql://localhost:3306/test?serverTimezone=Asia/Shanghai\u0026#34;); dataSource.setUsername(\u0026#34;root\u0026#34;); dataSource.setPassword(\u0026#34;root\u0026#34;); return dataSource; } //创建 JdbcTemplate 对象 @Bean public JdbcTemplate getJdbcTemplate(DataSource dataSource) { //到 ioc 容器中根据类型找到 dataSource JdbcTemplate jdbcTemplate = new JdbcTemplate(); //注入 dataSource jdbcTemplate.setDataSource(dataSource); return jdbcTemplate; } //创建事务管理器 @Bean public DataSourceTransactionManager getDataSourceTransactionManager(DataSource dataSource) { DataSourceTransactionManager transactionManager = new DataSourceTransactionManager(); transactionManager.setDataSource(dataSource); return transactionManager; } } 基于xml配置文件声明式事务管理\r\u0026lt;!--1 创建事务管理器--\u0026gt; \u0026lt;bean id=\u0026#34;transactionManager\u0026#34; class=\u0026#34;org.springframework.jdbc.datasource.DataSourceTransactionManager\u0026#34;\u0026gt; \u0026lt;!--注入数据源--\u0026gt; \u0026lt;property name=\u0026#34;dataSource\u0026#34; ref=\u0026#34;dataSource\u0026#34;\u0026gt;\u0026lt;/property\u0026gt; \u0026lt;/bean\u0026gt; \u0026lt;!--2 配置通知--\u0026gt; \u0026lt;tx:advice id=\u0026#34;txadvice\u0026#34;\u0026gt; \u0026lt;!--配置事务参数--\u0026gt; \u0026lt;tx:attributes\u0026gt; \u0026lt;!--指定哪种规则的方法上面添加事务--\u0026gt; \u0026lt;tx:method name=\u0026#34;accountMoney\u0026#34; propagation=\u0026#34;REQUIRED\u0026#34;/\u0026gt; \u0026lt;!--\u0026lt;tx:method name=\u0026#34;account*\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;/tx:attributes\u0026gt; \u0026lt;/tx:advice\u0026gt; \u0026lt;!--3 配置切入点和切面--\u0026gt; \u0026lt;aop:config\u0026gt; \u0026lt;!--配置切入点--\u0026gt; \u0026lt;aop:pointcut id=\u0026#34;pt\u0026#34; expression=\u0026#34;execution(* top.lukeewin.spring5.service.UserService.*(..))\u0026#34;/\u0026gt; \u0026lt;!--配置切面--\u0026gt; \u0026lt;aop:advisor advice-ref=\u0026#34;txadvice\u0026#34; pointcut-ref=\u0026#34;pt\u0026#34;/\u0026gt; \u0026lt;/aop:config\u0026gt; 能坚持阅读到最后的一定是铁粉了，感谢你的阅读，更多内容也可以访问我的个人博客\n本文转自 https://blog.csdn.net/qq_43907505/article/details/127323892，如有侵权，请联系删除。\n","date":"2026-04-11T00:00:00Z","permalink":"/p/spring-%E6%A1%86%E6%9E%B6%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0/","title":"Spring 框架学习笔记"},{"content":"SSM 框架学习笔记\rMyBatis\rMyBatis简介\rMyBatis历史\rMyBatis最初是Apache的一个开源项目iBatis, 2010年6月这个项目由Apache Software Foundation迁移到了Google Code。随着开发团队转投Google Code旗下，iBatis3.x正式更名为MyBatis。代码于2013年11月迁移到Github\niBatis一词来源于“internet”和“abatis”的组合，是一个基于Java的持久层框架。iBatis提供的持久层框架包括SQL Maps和Data Access Objects（DAO）\nMyBatis特性\rMyBatis 是支持定制化 SQL、存储过程以及高级映射的优秀的持久层框架 MyBatis 避免了几乎所有的 JDBC 代码和手动设置参数以及获取结果集 MyBatis可以使用简单的XML或注解用于配置和原始映射，将接口和Java的POJO（Plain Old Java Objects，普通的Java对象）映射成数据库中的记录 MyBatis 是一个 半自动的ORM（Object Relation Mapping）框架 MyBatis下载\rMyBatis下载地址 搭建MyBatis\r准备工作\rIDEA Maven Mysql sqlyog mybatis 创建Maven工程\r创建项目命名SSM\n创建模块命名为MyBatis_HelloWorld\n打包方式设置为jar\n\u0026lt;packaging\u0026gt;jar\u0026lt;/packaging\u0026gt; 引入依赖\n\u0026lt;dependencies\u0026gt; \u0026lt;!-- Mybatis核心 --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.mybatis\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mybatis\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.5.7\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!-- junit测试 --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;junit\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;junit\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;4.12\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;test\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!-- MySQL驱动 --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;mysql\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mysql-connector-java\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;5.1.3\u0026lt;/version\u0026gt; \u0026lt;!--因为我的maver库李MySQL版本为5.1.3所以我的版本号为5.1.3 根据自身版本修改--\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; ``` #### [创建数据库](https://so.csdn.net/so/search?q=%E5%88%9B%E5%BB%BA%E6%95%B0%E6%8D%AE%E5%BA%93\u0026amp;spm=1001.2101.3001.7020) 打开sqlyog创建数据库ssm并创建表t\\_user ```sql CREATE TABLE `t_user` ( `id` int(11) NOT NULL AUTO_INCREMENT, `username` varchar(20) DEFAULT NULL, `password` varchar(20) DEFAULT NULL, `age` int(11) DEFAULT NULL, `gender` char(1) DEFAULT NULL, `emall` varchar(50) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 创建实体类\r实体类创建到src的main目录中的java目录中\n创建User实体类并提供构造器与Get Set 方法\nprivate Integer id; private String username; private String password; private Integer age; private String gender; private String email; public User() { } public User(Integer id, String username, String password, Integer age, String gender, String email) { this.id = id; this.username = username; this.password = password; this.age = age; this.gender = gender; this.email = email; } public Integer getId() { return id; } public void setId(Integer id) { this.id = id; } public String getUsername() { return username; } public void setUsername(String username) { this.username = username; } public String getPassword() { return password; } public void setPassword(String password) { this.password = password; } public Integer getAge() { return age; } public void setAge(Integer age) { this.age = age; } public String getGender() { return gender; } public void setGender(String gender) { this.gender = gender; } public String getEmail() { return email; } public void setEmail(String email) { this.email = email; } 创建MyBatis的核心配置文件\r取名可以任意但通常习惯命名为mybatis-config.xml 当整合spring后这个配置文件可以省略，所以大家操作时可以直接复制，粘贴。\n核心配置文件主要作用是配置连接数据库的环境以及MyBatis的全局配置信息\n核心配置文件存放的位置是src\\main\\resources目录下\n核心配置文件中的标签必须按照固定的顺序(有的标签可以不写，但顺序一定不能乱)： properties、settings、typeAliases、typeHandlers、objectFactory、objectWrapperFactory、reflectorFactory、plugins、environments、databaseIdProvider、mappers\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34; ?\u0026gt;\u0026lt;!DOCTYPE configuration PUBLIC \u0026#34;-//MyBatis.org//DTD Config 3.0//EN\u0026#34; \u0026#34;http://MyBatis.org/dtd/MyBatis-3-config.dtd\u0026#34;\u0026gt;\u0026lt;configuration\u0026gt; \u0026lt;!-- environments：设置多个连接数据库的环境 属性：\tdefault：设置默认使用的环境的id --\u0026gt; \u0026lt;environments default=\u0026#34;development\u0026#34;\u0026gt; \u0026lt;!-- environment：设置具体的连接数据库的环境信息 属性：\tid：设置环境的唯一标识，可通过environments标签中的default设置某一个环境的id，表示默认使用的环境 --\u0026gt; \u0026lt;environment id=\u0026#34;development\u0026#34;\u0026gt; \u0026lt;!-- transactionManager：设置事务管理方式 属性：\ttype：设置事务管理方式，type=\u0026#34;JDBC|MANAGED\u0026#34;\ttype=\u0026#34;JDBC\u0026#34;：设置当前环境的事务管理都必须手动处理\ttype=\u0026#34;MANAGED\u0026#34;：设置事务被管理，例如spring中的AOP --\u0026gt; \u0026lt;transactionManager type=\u0026#34;JDBC\u0026#34;/\u0026gt; \u0026lt;!-- dataSource：设置数据源 属性：\ttype：设置数据源的类型，type=\u0026#34;POOLED|UNPOOLED|JNDI\u0026#34;\ttype=\u0026#34;POOLED\u0026#34;：使用数据库连接池，即会将创建的连接进行缓存，下次使用可以从缓存中直接获取，不需要重新创建\ttype=\u0026#34;UNPOOLED\u0026#34;：不使用数据库连接池，即每次使用连接都需要重新创建\ttype=\u0026#34;JNDI\u0026#34;：调用上下文中的数据源 --\u0026gt; \u0026lt;dataSource type=\u0026#34;POOLED\u0026#34;\u0026gt; \u0026lt;!--设置驱动类的全类名--\u0026gt; \u0026lt;property name=\u0026#34;driver\u0026#34; value=\u0026#34;com.mysql.seven.jdbc.Driver\u0026#34;/\u0026gt; \u0026lt;!--设置连接数据库的连接地址--\u0026gt; \u0026lt;property name=\u0026#34;url\u0026#34; value=\u0026#34;jdbc:mysql://localhost:13306\u0026#34;/\u0026gt; \u0026lt;!--设置连接数据库的用户名--\u0026gt; \u0026lt;property name=\u0026#34;username\u0026#34; value=\u0026#34;root\u0026#34;/\u0026gt; \u0026lt;!--设置连接数据库的密码--\u0026gt; \u0026lt;property name=\u0026#34;password\u0026#34; value=\u0026#34;XXXXXXX\u0026#34;/\u0026gt; \u0026lt;/dataSource\u0026gt; \u0026lt;/environment\u0026gt; \u0026lt;/environments\u0026gt; \u0026lt;!--引入映射文件--\u0026gt; \u0026lt;mappers\u0026gt; \u0026lt;mapper resource=\u0026#34;UserMapper.xml\u0026#34;/\u0026gt; \u0026lt;!-- 以包为单位，将包下所有的映射文件引入核心配置文件 注意：\t1. 此方式必须保证mapper接口和mapper映射文件必须在相同的包下\t2. mapper接口要和mapper映射文件的名字一致 --\u0026gt; \u0026lt;/mappers\u0026gt;\u0026lt;/configuration\u0026gt; 创建mapper接口\rMyBatis中的mapper接口相当于以前的dao。但是区别在于，mapper仅仅是接口，我们不需要提供实现类。\npublic interface UserMapper { int insertUser();} 创建MyBatis的映射文件\r在resources目录下创建mappers目录在mappers目录下创建UserMapper.xml配置文件\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34; ?\u0026gt;\u0026lt;!DOCTYPE mapper PUBLIC \u0026#34;-//mybatis.org//DTD Mapper 3.0//EN\u0026#34; \u0026#34;http://mybatis.org/dtd/mybatis-3-mapper.dtd\u0026#34;\u0026gt;\u0026lt;mapper namespace=\u0026#34;com.seven.mybatis.mapper.UserMapper\u0026#34;\u0026gt; \u0026lt;!-- mapper接口要和映射文件保持两个一致 1.mapper接口的全类名和映射文件的namespace一致 2.mapper接口的方法名要和映射文件的sql的id保持一致 --\u0026gt; \u0026lt;!-- int insertUser();--\u0026gt; \u0026lt;insert id=\u0026#34;insertUser\u0026#34;\u0026gt; insert into t_user values (null,\u0026#39;admin\u0026#39;,\u0026#39;123456\u0026#39;,23,\u0026#39;男\u0026#39;,\u0026#39;2289938711@qq.com\u0026#39;) \u0026lt;/insert\u0026gt; \u0026lt;/mapper\u0026gt; 创建测试类\r如果报错的话就是没有把mybatis添加到库里面\npackage com.seven.mybatis; import com.seven.mybatis.mapper.UserMapper;import org.apache.ibatis.io.Resources;import org.apache.ibatis.session.SqlSession;import org.apache.ibatis.session.SqlSessionFactory;import org.apache.ibatis.session.SqlSessionFactoryBuilder;import org.junit.Test; import java.io.IOException;import java.io.InputStream; /** * @name hk * @time 2022-08-31-17:59 */public class MyBatisTest { @Test public void test() throws IOException { //获取核心配置文件的输入流 InputStream is = Resources.getResourceAsStream(\u0026#34;mybatis-config.xml\u0026#34;); //获取SqlSessionFactoryBuilder对象 SqlSessionFactoryBuilder sqlSessionFactoryBuilder = new SqlSessionFactoryBuilder(); //获取SqlSessionFactory对象 SqlSessionFactory build = sqlSessionFactoryBuilder.build(is); //获取SQL的会话对象SqlSession,是MyBatis提供的操作数据库的对象 SqlSession sqlSession = build.openSession(); //获取UserMapper代理实现类对象 UserMapper mapper = sqlSession.getMapper(UserMapper.class); int i = mapper.insertUser(); System.out.println(i); sqlSession.close(); }} 查看sqlyog是否添加成功\n2022/8/31 20:11:00\n优化MyBatis框架\r//获取核心配置文件的输入流 InputStream is = Resources.getResourceAsStream(\u0026#34;mybatis-config.xml\u0026#34;); //获取SqlSessionFactoryBuilder对象 SqlSessionFactoryBuilder sqlSessionFactoryBuilder = new SqlSessionFactoryBuilder(); //获取SqlSessionFactory对象 SqlSessionFactory build = sqlSessionFactoryBuilder.build(is); //获取SQL的会话对象SqlSession()不会自动提交事务,是MyBatis提供的操作数据库的对象 //SqlSession sqlSession = build.openSession(); //获取SQL的会话对象SqlSession(true)会自动提交事务,是MyBatis提供的操作数据库的对象 SqlSession sqlSession = build.openSession(true); //获取UserMapper代理实现类对象 //UserMapper mapper = sqlSession.getMapper(UserMapper.class); //提供sql以及唯一标识找到sql并执行,唯一标识是namespace.sqlId int i = sqlSession.insert(\u0026#34;com.seven.mybatis.mapper.UserMapper.insertUser\u0026#34;); //int i = mapper.insertUser(); System.out.println(i); //提交事务 //sqlSession.commit(); sqlSession.close(); 添加log4j日志文件\r在pom.xml里添加配置\r\u0026lt;!-- log4j日志 --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;log4j\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;log4j\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.2.17\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; 在resources里创建log4j.xml\r\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34; ?\u0026gt;\u0026lt;!DOCTYPE log4j:configuration SYSTEM \u0026#34;log4j.dtd\u0026#34;\u0026gt;\u0026lt;log4j:configuration xmlns:log4j=\u0026#34;http://jakarta.apache.org/log4j/\u0026#34;\u0026gt; \u0026lt;appender name=\u0026#34;STDOUT\u0026#34; class=\u0026#34;org.apache.log4j.ConsoleAppender\u0026#34;\u0026gt; \u0026lt;param name=\u0026#34;Encoding\u0026#34; value=\u0026#34;UTF-8\u0026#34; /\u0026gt; \u0026lt;layout class=\u0026#34;org.apache.log4j.PatternLayout\u0026#34;\u0026gt; \u0026lt;param name=\u0026#34;ConversionPattern\u0026#34; value=\u0026#34;%-5p %d{MM-dd HH:mm:ss,SSS} %m (%F:%L) \\n\u0026#34; /\u0026gt; \u0026lt;/layout\u0026gt; \u0026lt;/appender\u0026gt; \u0026lt;logger name=\u0026#34;java.sql\u0026#34;\u0026gt; \u0026lt;level value=\u0026#34;debug\u0026#34; /\u0026gt; \u0026lt;/logger\u0026gt; \u0026lt;logger name=\u0026#34;org.apache.ibatis\u0026#34;\u0026gt; \u0026lt;level value=\u0026#34;info\u0026#34; /\u0026gt; \u0026lt;/logger\u0026gt; \u0026lt;root\u0026gt; \u0026lt;level value=\u0026#34;debug\u0026#34; /\u0026gt; \u0026lt;appender-ref ref=\u0026#34;STDOUT\u0026#34; /\u0026gt; \u0026lt;/root\u0026gt;\u0026lt;/log4j:configuration\u0026gt; 封装获取Session\rpackage com.seven.mybatis.utils; import org.apache.ibatis.io.Resources;import org.apache.ibatis.session.SqlSession;import org.apache.ibatis.session.SqlSessionFactory;import org.apache.ibatis.session.SqlSessionFactoryBuilder; import java.io.IOException;import java.io.InputStream; /** * @name hk * @time 2022-09-01-15:59 */public class SqlSessionUtil { public static SqlSession getSqlSession(){ SqlSession sqlSession =null; try { //获取核心配置文件的输入流 InputStream is = Resources.getResourceAsStream(\u0026#34;mybatis-config.xml\u0026#34;); //获取SqlSessionFactoryBuilder对象 SqlSessionFactoryBuilder builder = new SqlSessionFactoryBuilder(); //获取SqlSessionFactory对象 SqlSessionFactory build = builder.build(is); //获取SqlSession对象 sqlSession = build.openSession(true); } catch (IOException e) { e.printStackTrace(); } return sqlSession; }} 测试修改用户\r\u0026lt;!-- void upDateUser();--\u0026gt; \u0026lt;update id=\u0026#34;upDateUser\u0026#34;\u0026gt; update t_user set gender = \u0026#39;女\u0026#39; where id =2 \u0026lt;/update\u0026gt; public void upData(){ SqlSession sqlSession = SqlSessionUtil.getSqlSession(); UserMapper mapper = sqlSession.getMapper(UserMapper.class); mapper.upDateUser(); sqlSession.close(); } 测试删除用户\r\u0026lt;!-- void deleteUser();--\u0026gt; \u0026lt;delete id=\u0026#34;deleteUser\u0026#34;\u0026gt; delete from t_user where id =3 \u0026lt;/delete\u0026gt; public void delete(){ SqlSession session = SqlSessionUtil.getSqlSession(); UserMapper mapper = session.getMapper(UserMapper.class); mapper.deleteUser(); session.close(); } 测试查询用户\r查询单行信息 \u0026lt;!-- User getUserById();--\u0026gt; \u0026lt;!-- resultType:设置结果类型，即查询到的数据要转换成的Java类型 resultMap：自定义映射，处理多对一或一对多的映射关系 --\u0026gt; \u0026lt;select id=\u0026#34;getUserById\u0026#34; resultType=\u0026#34;com.seven.mybatis.pojo.User\u0026#34;\u0026gt; select * from t_user where id= 1 \u0026lt;/select\u0026gt; public void getUserById(){ SqlSession session = SqlSessionUtil.getSqlSession(); UserMapper mapper = session.getMapper(UserMapper.class); User userById = mapper.getUserById(); System.out.println(userById); session.close(); } 查询全部信息 \u0026lt;!-- List\u0026lt;User\u0026gt; getAllUser(); --\u0026gt; \u0026lt;select id=\u0026#34;getAllUser\u0026#34; resultType=\u0026#34;com.seven.mybatis.pojo.User\u0026#34;\u0026gt; select * from t_user \u0026lt;/select\u0026gt; public void getAllUser(){ SqlSession session = SqlSessionUtil.getSqlSession(); UserMapper mapper = session.getMapper(UserMapper.class); List\u0026lt;User\u0026gt; allUser = mapper.getAllUser(); allUser.forEach(System.out::println); session.close(); } MyBatis核心配置文件\rtypeAliases标签 \u0026lt;typeAliases\u0026gt;\t\u0026lt;!-- typeAlias：设置某个具体的类型的别名 属性： type：需要设置别名的类型的全类名 alias：设置此类型的别名，且别名不区分大小写。若不设置此属性，该类型拥有默认的别名，即类名 --\u0026gt;\t\u0026lt;!--\u0026lt;typeAlias type=\u0026#34;com.seven.mybatis.bean.User\u0026#34;\u0026gt;\u0026lt;/typeAlias\u0026gt;--\u0026gt;\t\u0026lt;!--\u0026lt;typeAlias type=\u0026#34;com.seven.mybatis.bean.User\u0026#34; alias=\u0026#34;user\u0026#34;\u0026gt; \u0026lt;/typeAlias\u0026gt;--\u0026gt;\t\u0026lt;!--以包为单位，设置改包下所有的类型都拥有默认的别名，即类名且不区分大小写--\u0026gt;\t\u0026lt;package name=\u0026#34;com.seven.mybatis.bean\u0026#34;/\u0026gt;\t\u0026lt;/typeAliases\u0026gt; environments标签 \u0026lt;environments default=\u0026#34;development\u0026#34;\u0026gt; \u0026lt;!-- environment：设置具体的连接数据库的环境信息 属性：\tid：设置环境的唯一标识，可通过environments标签中的default设置某一个环境的id，表示默认使用的环境 --\u0026gt; \u0026lt;environment id=\u0026#34;development\u0026#34;\u0026gt; transactionManager标签 \u0026lt;!-- transactionManager：设置事务管理方式 属性：\ttype：设置事务管理方式，type=\u0026#34;JDBC|MANAGED\u0026#34;\ttype=\u0026#34;JDBC\u0026#34;：设置当前环境的事务管理都必须手动处理\ttype=\u0026#34;MANAGED\u0026#34;：设置事务被管理，例如spring中的AOP --\u0026gt; \u0026lt;transactionManager type=\u0026#34;JDBC\u0026#34;/\u0026gt; properties标签 \u0026lt;properties resource=\u0026#34;jdbc.properties\u0026#34;\u0026gt;\u0026lt;/properties\u0026gt; \u0026lt;!-- 引用配置文件 --\u0026gt; dateSource标签 \u0026lt;!-- dataSource：设置数据源 属性：\ttype：设置数据源的类型，type=\u0026#34;POOLED|UNPOOLED|JNDI\u0026#34;\ttype=\u0026#34;POOLED\u0026#34;：使用数据库连接池，即会将创建的连接进行缓存，下次使用可以从缓存中直接获取，不需要重新创建\ttype=\u0026#34;UNPOOLED\u0026#34;：不使用数据库连接池，即每次使用连接都需要重新创建\ttype=\u0026#34;JNDI\u0026#34;：调用上下文中的数据源 --\u0026gt; \u0026lt;dataSource type=\u0026#34;POOLED\u0026#34;\u0026gt; property标签 \u0026lt;!--设置驱动类的全类名--\u0026gt; \u0026lt;property name=\u0026#34;driver\u0026#34; value=\u0026#34;${jdbc.driver}\u0026#34;/\u0026gt; \u0026lt;!--设置连接数据库的连接地址--\u0026gt; \u0026lt;property name=\u0026#34;url\u0026#34; value=\u0026#34;${jdbc.url}\u0026#34;/\u0026gt; \u0026lt;!--设置连接数据库的用户名--\u0026gt; \u0026lt;property name=\u0026#34;username\u0026#34; value=\u0026#34;${jdbc.uname}\u0026#34;/\u0026gt; \u0026lt;!--设置连接数据库的密码--\u0026gt; \u0026lt;property name=\u0026#34;password\u0026#34; value=\u0026#34;${jdbc.password}\u0026#34;/\u0026gt; mapper标签 \u0026lt;mappers\u0026gt; \u0026lt;!-- 以具体的xml为单位引入核心配置文件 --\u0026gt; \u0026lt;!-- \u0026lt;mapper resource=\u0026#34;com.seven.mybatis.mapper.UserMapper.xml\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!-- 以包为单位，将包下所有的映射文件引入核心配置文件 注意：\t1. 此方式必须保证mapper接口和mapper映射文件必须在相同的包下\t2. mapper接口要和mapper映射文件的名字一致 --\u0026gt; \u0026lt;package name=\u0026#34;com.seven.mybatis.mapper\u0026#34;\u0026gt;\u0026lt;/package\u0026gt; \u0026lt;/mappers\u0026gt; 不好意思！开学比较忙马上更新9.22\nMyBatis获取\r#{}获取 #{}的本质就是占位符赋值 （自动加单引号） ${}获取${}的本质就是字符串拼接（需要手动添加单引号） 若mapper接口方法的参数为多个字面量类型\n此时mybatis会把参数放到map集合中，以两种方式存储\narg1，arg2\u0026hellip;为键，以参数为值。 param1，param2\u0026hellip;为键，以参数为值。 因此只需要通过#{}和${}访问map集合的键，就可以获取相对应的值，一定要注意${}的单引号问题。\n若mapper接口方法的参数为map集合类型的参数\n只需通过#{}和${}访问map集合的键，就可以获得相对应的值，一定要注意${}的单引号问题。\n若mapper接口方法的参数为实体类类型的参数\n只需要通过#{}和${}访问实体类中的属性名，就可以获得相对应的属性值，一定要注意${}的单引号问题。\n可以在mapper接口方法的参数上设置@param注解\n此时mybatis会把这些参数放到map中，以两种方式进行存储。\n以@param注解的value属性值为键，以参数为值。 以param1，param2\u0026hellip;为键，以参数为值。 只需要通过#{}和${}访问map集合的键，就可以获取相应的值，一定要注意${}的单引号问题\nMyBatis查询\r根据ID获取User对象\n/** * 通过id获取用户数据 * @param id * @return */ User getUserById(@Param(\u0026#34;id\u0026#34;) Integer id); \u0026lt;!-- User getUserById(@Param(\u0026#34;id\u0026#34;) Integer id); --\u0026gt; \u0026lt;select id=\u0026#34;getUserById\u0026#34; resultType=\u0026#34;User\u0026#34;\u0026gt; select * from t_user where id = #{id} \u0026lt;/select\u0026gt; 查询所有t_user表的数据\n/** * 获取所有用户数据 * @return * * 如果获取表中所有的数据需要list集合接收 * */ List\u0026lt;User\u0026gt; getAllUser();/** * 获取所有用户数据 * @return * 如果用实体类对象接收则会报错 报错信息为 * org.apache.ibatis.exceptions.TooManyResultsException: Expected one result (or null) * to be returned by selectOne(), but found: 5 * 期望返回一个结果发现返回5条数据 */ User getAllUser(); \u0026lt;!-- List\u0026lt;User\u0026gt; getAllUser(); --\u0026gt; \u0026lt;select id=\u0026#34;getAllUser\u0026#34; resultType=\u0026#34;User\u0026#34;\u0026gt; select * from t_user \u0026lt;/select\u0026gt;\u0026lt;!-- User getAllUser(); --\u0026gt; \u0026lt;select id=\u0026#34;getAllUser\u0026#34; resultType=\u0026#34;User\u0026#34;\u0026gt; select * from t_user \u0026lt;/select\u0026gt; 利用map集合获取数据\n/** * 用map集合获取数据 * @param id * @return * 以字段名为键 以字段的值为值 如果某一字段值为null 则不会放到map集合中 */ Map\u0026lt;String,Object\u0026gt; getAllUserByMap(@Param(\u0026#34;id\u0026#34;) Integer id); \u0026lt;!-- Map\u0026lt;String,Object\u0026gt; getAllUserByMap(@Param(\u0026#34;id\u0026#34;) Integer id); --\u0026gt; \u0026lt;select id=\u0026#34;getAllUserByMap\u0026#34; resultType=\u0026#34;map\u0026#34;\u0026gt; select * from t_user where id =#{id} \u0026lt;/select\u0026gt; 利用map集合获取全部数据\n/** * 用map集合获取数据mapKey为键 以获取到的数据为值 * @return */ @MapKey(\u0026#34;id\u0026#34;) Map\u0026lt;String,Object\u0026gt; getAllUserByMaps(); \u0026lt;!-- Map\u0026lt;String,Object\u0026gt; getAllUserByMaps(); --\u0026gt; \u0026lt;select id=\u0026#34;getAllUserByMaps\u0026#34; resultType=\u0026#34;map\u0026#34;\u0026gt; select * from t_user \u0026lt;/select\u0026gt; 2022年9月25日 星期日 23：20：55 🕊\n特殊SQL的执行\r模糊查询\n/*** 测试模糊查询* @return*/ List\u0026lt;User\u0026gt; testMohu(@Param(\u0026#34;mohu\u0026#34;) String mohu); \u0026lt;!--List\u0026lt;User\u0026gt; testMohu(@Param(\u0026#34;mohu\u0026#34;) String mohu);--\u0026gt; \u0026lt;select id=\u0026#34;testMohu\u0026#34; resultType=\u0026#34;User\u0026#34;\u0026gt; \u0026lt;!-- 利用字符串拼接模糊查询 用${}拼接 --\u0026gt; \u0026lt;!--select * from t_user where username like \u0026#39;%${mohu}%\u0026#39;--\u0026gt; \u0026lt;!-- 利用concat函数字符串拼接 麻烦 --\u0026gt; \u0026lt;!--select * from t_user where username like concat(\u0026#39;%\u0026#39;,#{mohu},\u0026#39;%\u0026#39;)--\u0026gt; \u0026lt;!-- 用的最多的方式 建议使用 --\u0026gt; select * from t_user where username like \u0026#34;%\u0026#34;#{mohu}\u0026#34;%\u0026#34; \u0026lt;/select\u0026gt; \u0026lt;!-- 都会最好 --\u0026gt; 批量删除\n/*** 批量删除 * @param ids * @return */ int deleteMore(@Param(\u0026#34;ids\u0026#34;) String ids); \u0026lt;!--int deleteMore(@Param(\u0026#34;ids\u0026#34;) String ids);--\u0026gt; \u0026lt;delete id=\u0026#34;deleteMore\u0026#34;\u0026gt; \u0026lt;!-- 只能使用${} 用#{}会报错 以后可用foreach 解决 --\u0026gt; delete from t_user where id in (${ids})\u0026lt;/delete\u0026gt; 动态设置表名\n/*** 动态设置表名，查询所有的用户信息 * @param tableName * @return */ List\u0026lt;User\u0026gt; getAllUser(@Param(\u0026#34;tableName\u0026#34;) String tableName); \u0026lt;!--List\u0026lt;User\u0026gt; getAllUser(@Param(\u0026#34;tableName\u0026#34;) String tableName);--\u0026gt; \u0026lt;!-- 依然是#{}和${}的问题 --\u0026gt;\u0026lt;select id=\u0026#34;getAllUser\u0026#34; resultType=\u0026#34;User\u0026#34;\u0026gt; select * from ${tableName} \u0026lt;/select\u0026gt; 获取自增的主键\n/*** 添加用户信息 * @param user * @return */ int insertUser(User user); \u0026lt;!--int insertUser(User user);--\u0026gt; \u0026lt;insert id=\u0026#34;insertUser\u0026#34; useGeneratedKeys=\u0026#34;true\u0026#34; keyProperty=\u0026#34;id\u0026#34;\u0026gt; \u0026lt;!-- useGeneratedKeys：设置使用自增的主键keyProperty：因为增删改有统一的返回值是受影响的行数，因此只能将获取的自增的主键放在传输的参数user对象的某个属性中--\u0026gt; insert into t_user values(null,#{username},#{password},#{age},#{sex}) \u0026lt;/insert\u0026gt; 自定义映射resultMap\rresultMup处理字段和属性\n若字段名和实体类中的属性名不一致，则可以通过resultMap设置自定义映射\n\u0026lt;!--resultMap：设置自定义映射 属性： id：表示自定义映射的唯一标识 type：查询的数据要映射的实体类的类型 子标签： id：设置主键的映射关系 result：设置普通字段的映射关系association：设置多对一的映射关系(处理实体类类型的属性)collection：设置一对多的映射关系 属性： property：设置映射关系中实体类中的属性名 column：设置映射关系中表中的字段名 --\u0026gt; \u0026lt;resultMap id=\u0026#34;userMap\u0026#34; type=\u0026#34;User\u0026#34;\u0026gt; \u0026lt;id property=\u0026#34;id\u0026#34; column=\u0026#34;id\u0026#34;\u0026gt;\u0026lt;/id\u0026gt; \u0026lt;result property=\u0026#34;userName\u0026#34; column=\u0026#34;user_name\u0026#34;\u0026gt;\u0026lt;/result\u0026gt; \u0026lt;result property=\u0026#34;password\u0026#34; column=\u0026#34;password\u0026#34;\u0026gt;\u0026lt;/result\u0026gt; \u0026lt;result property=\u0026#34;age\u0026#34; column=\u0026#34;age\u0026#34;\u0026gt;\u0026lt;/result\u0026gt; \u0026lt;result property=\u0026#34;sex\u0026#34; column=\u0026#34;sex\u0026#34;\u0026gt;\u0026lt;/result\u0026gt; \u0026lt;/resultMap\u0026gt; \u0026lt;!--List\u0026lt;User\u0026gt; testMohu(@Param(\u0026#34;mohu\u0026#34;) String mohu);--\u0026gt; \u0026lt;select id=\u0026#34;testMohu\u0026#34; resultMap=\u0026#34;userMap\u0026#34;\u0026gt; \u0026lt;!--select * from t_user where username like \u0026#39;%${mohu}%\u0026#39;--\u0026gt; select id,user_name,password,age,sex from t_user where user_name like concat(\u0026#39;%\u0026#39;,#{mohu},\u0026#39;%\u0026#39;) \u0026lt;/select\u0026gt; 若字段名和实体类中的属性名不一致， 但是字段名符合数据库的规则（使用_） ，实体类中的\n属性\n名符合 Java 的规则（使用驼峰）\n此时也可通过以下两种方式处理字段名和实体类中的属性的映射关系\na\u0026gt; 可以通过为字段起别名的方式，保证和实体类中的属性名保持一致\nb\u0026gt; 可以在 MyBatis 的核心配置文件中设置一个全局配置信息 mapUnderscoreToCamelCase ，可\n以在查询表中数据时，自动将 _ 类型的字段名转换为驼峰\n例如：字段名 user_name ，设置了 mapUnderscoreToCamelCase ，此时字段名就会转换为\nuserName\n多对一映射处理\r查询员工信息及员工所对应的部门信息\n级联方式处理映射关系\n\u0026lt;resultMap id=\u0026#34;empDeptMap\u0026#34; type=\u0026#34;Emp\u0026#34;\u0026gt; \u0026lt;id column=\u0026#34;eid\u0026#34; property=\u0026#34;eid\u0026#34;\u0026gt;\u0026lt;/id\u0026gt; \u0026lt;result column=\u0026#34;ename\u0026#34; property=\u0026#34;ename\u0026#34;\u0026gt;\u0026lt;/result\u0026gt; \u0026lt;result column=\u0026#34;age\u0026#34; property=\u0026#34;age\u0026#34;\u0026gt;\u0026lt;/result\u0026gt; \u0026lt;result column=\u0026#34;sex\u0026#34; property=\u0026#34;sex\u0026#34;\u0026gt;\u0026lt;/result\u0026gt; \u0026lt;result column=\u0026#34;did\u0026#34; property=\u0026#34;dept.did\u0026#34;\u0026gt;\u0026lt;/result\u0026gt; \u0026lt;result column=\u0026#34;dname\u0026#34; property=\u0026#34;dept.dname\u0026#34;\u0026gt;\u0026lt;/result\u0026gt; \u0026lt;/resultMap\u0026gt; \u0026lt;!--Emp getEmpAndDeptByEid(@Param(\u0026#34;eid\u0026#34;) int eid);--\u0026gt; \u0026lt;select id=\u0026#34;getEmpAndDeptByEid\u0026#34; resultMap=\u0026#34;empDeptMap\u0026#34;\u0026gt; select emp.*,dept.* from t_emp emp left join t_dept dept on emp.did = dept.did where emp.eid = #{eid} \u0026lt;/select\u0026gt; 使用association处理映射关系\n\u0026lt;resultMap id=\u0026#34;empDeptMap\u0026#34; type=\u0026#34;Emp\u0026#34;\u0026gt; \u0026lt;id column=\u0026#34;eid\u0026#34; property=\u0026#34;eid\u0026#34;\u0026gt;\u0026lt;/id\u0026gt; \u0026lt;result column=\u0026#34;ename\u0026#34; property=\u0026#34;ename\u0026#34;\u0026gt;\u0026lt;/result\u0026gt; \u0026lt;result column=\u0026#34;age\u0026#34; property=\u0026#34;age\u0026#34;\u0026gt;\u0026lt;/result\u0026gt; \u0026lt;result column=\u0026#34;sex\u0026#34; property=\u0026#34;sex\u0026#34;\u0026gt;\u0026lt;/result\u0026gt; \u0026lt;!-- association：处理多对一的映射关系（处理实体类的属性） property：处理需要处理映射关系的属性的属性名 javaType：设置需要处理的属性的类型 --\u0026gt; \u0026lt;association property=\u0026#34;dept\u0026#34; javaType=\u0026#34;Dept\u0026#34;\u0026gt; \u0026lt;id column=\u0026#34;did\u0026#34; property=\u0026#34;did\u0026#34;\u0026gt;\u0026lt;/id\u0026gt; \u0026lt;result column=\u0026#34;dname\u0026#34; property=\u0026#34;dname\u0026#34;\u0026gt;\u0026lt;/result\u0026gt; \u0026lt;/association\u0026gt; \u0026lt;/resultMap\u0026gt; \u0026lt;!--Emp getEmpAndDeptByEid(@Param(\u0026#34;eid\u0026#34;) int eid);--\u0026gt; \u0026lt;select id=\u0026#34;getEmpAndDeptByEid\u0026#34; resultMap=\u0026#34;empDeptMap\u0026#34;\u0026gt; select emp.*,dept.* from t_emp emp left join t_dept dept on emp.did = dept.did where emp.eid = #{eid} \u0026lt;/select\u0026gt; 分步查询\r查询员工信息\n/*** 通过分步查询查询员工信息 * @param eid * @return */ Emp getEmpByStep(@Param(\u0026#34;eid\u0026#34;) int eid); \u0026lt;resultMap id=\u0026#34;empDeptStepMap\u0026#34; type=\u0026#34;Emp\u0026#34;\u0026gt; \u0026lt;id column=\u0026#34;eid\u0026#34; property=\u0026#34;eid\u0026#34;\u0026gt;\u0026lt;/id\u0026gt; \u0026lt;result column=\u0026#34;ename\u0026#34; property=\u0026#34;ename\u0026#34;\u0026gt;\u0026lt;/result\u0026gt; \u0026lt;result column=\u0026#34;age\u0026#34; property=\u0026#34;age\u0026#34;\u0026gt;\u0026lt;/result\u0026gt; \u0026lt;result column=\u0026#34;sex\u0026#34; property=\u0026#34;sex\u0026#34;\u0026gt;\u0026lt;/result\u0026gt; \u0026lt;!--select：设置分步查询，查询某个属性的值的sql的标识（namespace.sqlId） column：将sql以及查询结果中的某个字段设置为分步查询的条件 --\u0026gt; \u0026lt;association property=\u0026#34;dept\u0026#34;select=\u0026#34;com.atguigu.MyBatis.mapper.DeptMapper.getEmpDeptByStep\u0026#34; column=\u0026#34;did\u0026#34;\u0026gt; \u0026lt;/association\u0026gt; \u0026lt;/resultMap\u0026gt; \u0026lt;!--Emp getEmpByStep(@Param(\u0026#34;eid\u0026#34;) int eid);--\u0026gt; \u0026lt;select id=\u0026#34;getEmpByStep\u0026#34; resultMap=\u0026#34;empDeptStepMap\u0026#34;\u0026gt; select * from t_emp where eid = #{eid} \u0026lt;/select\u0026gt; 根据员工所对应的部门id查询部门信息\n/*** 分步查询的第二步： 根据员工所对应的did查询部门信息 * @param did * @return */ Dept getEmpDeptByStep(@Param(\u0026#34;did\u0026#34;) int did); \u0026lt;!--Dept getEmpDeptByStep(@Param(\u0026#34;did\u0026#34;) int did);--\u0026gt; \u0026lt;select id=\u0026#34;getEmpDeptByStep\u0026#34; resultType=\u0026#34;Dept\u0026#34;\u0026gt; select * from t_dept where did = #{did} \u0026lt;/select\u0026gt; 一步一步查先查第一步查到emp表的信息然后mybatis会自动调用 select标签的接口并把查询到的数据通过column 内的遍历传进getEmpDeptByStep的参数中执行第二个查询语句并返回dept类型的数据（大概就是这个意思，有错误欢迎告诉我我会及时更正 一起加油！！）\n延迟加载\n\u0026lt;settings\u0026gt; \u0026lt;!--将下划线映射为驼峰--\u0026gt; \u0026lt;setting name=\u0026#34;mapUnderscoreToCamelCase\u0026#34; value=\u0026#34;true\u0026#34;/\u0026gt; \u0026lt;!--开启延迟加载--\u0026gt; \u0026lt;setting name=\u0026#34;lazyLoadingEnabled\u0026#34; value=\u0026#34;true\u0026#34;/\u0026gt; \u0026lt;!--按需加载--\u0026gt; \u0026lt;setting name=\u0026#34;aggressiveLazyLoading\u0026#34; value=\u0026#34;false\u0026#34;/\u0026gt; \u0026lt;/settings\u0026gt; 分步查询的优点：可以实现延迟加载\n但是必须在 核心配置文件中 设置全局配置信息：\nlazyLoadingEnabled ：延迟加载的全局开关。当开启时，所有关联对象都会延迟加载\naggressiveLazyLoading ：当开启时，任何方法的调用都会加载该对象的所有属性。\n否则，每个属性会按需加载此时就可以实现按需加载，获取的数据是什么，就只会执行相应的sql 。\n此时可通过 association 和collection中的 fetchType 属性设置当前的分步查询是否使用延迟加载， fetchType=\u0026ldquo;lazy( 延迟加载)|eager( 立即加载 )\u0026rdquo;\n动态SQL\rMyBatis框架的动态SQL技术是一种根据特定条件动态拼接SQL语句的功能，它存在的意义是为了解决拼接SQL语句字符串时的痛点问题。\nif标签\rif标签可通过test属性的表达式进行判断，若判断表达式结果为true，则标签中的内容会执行；反之标签中的内容不会执行。\n\u0026lt;!--List\u0026lt;Emp\u0026gt; getEmpListByCondition(Emp emp);--\u0026gt; \u0026lt;select id=\u0026#34;getEmpListByMoreTJ\u0026#34; resultType=\u0026#34;Emp\u0026#34;\u0026gt; \u0026lt;!-- 1=1是因为如果下方条件都不满足的情况下where会报错 还有其他处理方式在下面 --\u0026gt;select * from t_emp where 1=1 \u0026lt;if test=\u0026#34;ename != \u0026#39;\u0026#39; and ename != null\u0026#34;\u0026gt; and ename = #{ename} \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;age != \u0026#39;\u0026#39; and age != null\u0026#34;\u0026gt; and age = #{age} \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;sex != \u0026#39;\u0026#39; and sex != null\u0026#34;\u0026gt; and sex = #{sex} \u0026lt;/if\u0026gt; \u0026lt;/select\u0026gt; 休息休息QAQ 2022年10月6日18：11：11\nwhere标签\r若where标签中的if条件都不满足，则where标签没有任何功能，即不会添加where关键字 若where标签中的if条件满足，则where标签会自动添加where关键字，并将条件最前方多余的and去掉 注意：where标签不能去掉条件最后多余的and \u0026lt;select id=\u0026#34;getEmpByConditionTwo\u0026#34; resultType=\u0026#34;Emp\u0026#34;\u0026gt; select * from t_emp \u0026lt;where\u0026gt; \u0026lt;if test=\u0026#34;empName != null and empName != \u0026#39;\u0026#39;\u0026#34;\u0026gt; emp_name = #{empName} \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;age != null and age != \u0026#39;\u0026#39;\u0026#34;\u0026gt; \u0026lt;!--即使上面的条件不成立前面的and也会自动消除--\u0026gt; and age = #{age} \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;gender != null and gender != \u0026#39;\u0026#39;\u0026#34;\u0026gt; and gender = #{gender} \u0026lt;/if\u0026gt; \u0026lt;/where\u0026gt; \u0026lt;/select\u0026gt; trim标签\rprefix、suffix：在标签中内容前面或后面添加指定内容 prefixOverrides、suffixOverrides：在标签中内容前面或后面去掉指定内容 \u0026lt;select id=\u0026#34;getEmpByCondition\u0026#34; resultType=\u0026#34;Emp\u0026#34;\u0026gt; select * from t_emp \u0026lt;!-- prefix：在内容的前面添加 where 。 suffixOverrides：在内容的后面去掉 and --\u0026gt; \u0026lt;trim prefix=\u0026#34;where\u0026#34; suffixOverrides=\u0026#34;and\u0026#34;\u0026gt; \u0026lt;if test=\u0026#34;empName != null and empName != \u0026#39;\u0026#39;\u0026#34;\u0026gt; emp_name = #{empName} and \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;age != null and age != \u0026#39;\u0026#39;\u0026#34;\u0026gt; age = #{age} and \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;gender != null and gender != \u0026#39;\u0026#39;\u0026#34;\u0026gt; gender = #{gender} \u0026lt;/if\u0026gt; \u0026lt;/trim\u0026gt; \u0026lt;/select\u0026gt; choose、when、otherwise 标签\r相当于java中的if\u0026hellip;else if\u0026hellip;else when至少设置一个，otherwise最多设置一个\n\u0026lt;select id=\u0026#34;getEmpByChoose\u0026#34; resultType=\u0026#34;Emp\u0026#34;\u0026gt; select * from t_emp \u0026lt;where\u0026gt; \u0026lt;choose\u0026gt; \u0026lt;when test=\u0026#34;empName != null and empName != \u0026#39;\u0026#39;\u0026#34;\u0026gt; emp_name = #{empName} \u0026lt;/when\u0026gt; \u0026lt;when test=\u0026#34;age != null and age != \u0026#39;\u0026#39;\u0026#34;\u0026gt; age = #{age} \u0026lt;/when\u0026gt; \u0026lt;when test=\u0026#34;gender != null and gender != \u0026#39;\u0026#39;\u0026#34;\u0026gt; gender = #{gender} \u0026lt;/when\u0026gt; \u0026lt;/choose\u0026gt; \u0026lt;/where\u0026gt; \u0026lt;/select\u0026gt; foreach 标签\r循环添加\n\u0026lt;insert id=\u0026#34;insertMoreEmp\u0026#34;\u0026gt; insert into t_emp values \u0026lt;!-- collection：设置要循环的数组或集合 item：用一个字符串表示数组或集合中的每一个数据 separator：设置每次循环的数据之间的分隔符 open：循环的所有内容以什么开始 close：循环的所有内容以什么结束 --\u0026gt; \u0026lt;foreach collection=\u0026#34;emps\u0026#34; item=\u0026#34;emp\u0026#34; separator=\u0026#34;,\u0026#34;\u0026gt; (null,#{emp.empName},#{emp.age},#{emp.gender},null) \u0026lt;/foreach\u0026gt; \u0026lt;/insert\u0026gt; 循环删除\n\u0026lt;!-- 两种删除方式--\u0026gt; \u0026lt;delete id=\u0026#34;deleteMoreEmp\u0026#34;\u0026gt; \u0026lt;!--delete from t_emp where emp_id in \u0026lt;!-- 第一种利用mysql的in函数进行批量删除 --\u0026gt; \u0026lt;foreach collection=\u0026#34;empIds\u0026#34; item=\u0026#34;empId\u0026#34; separator=\u0026#34;,\u0026#34; open=\u0026#34;(\u0026#34; close=\u0026#34;)\u0026#34;\u0026gt; #{empId} \u0026lt;/foreach\u0026gt;--\u0026gt; \u0026lt;!-- 第二种 利用 where 和 or 进行批量删除 --\u0026gt; delete from t_emp where \u0026lt;foreach collection=\u0026#34;empIds\u0026#34; item=\u0026#34;empId\u0026#34; separator=\u0026#34;or\u0026#34;\u0026gt; emp_id = #{empId} \u0026lt;/foreach\u0026gt; \u0026lt;/delete\u0026gt; sql标签\r可以记录一段sql，在需要用的地方使用include标签进行引用\n\u0026lt;sql id=\u0026#34;empColumns\u0026#34;\u0026gt; emp_id,emp_name,age,gender,dept_id \u0026lt;/sql\u0026gt; \u0026lt;!-- 用来引用sql 标签中的字段 --\u0026gt;\u0026lt;include refid=\u0026#34;empColumns\u0026#34;\u0026gt;\u0026lt;/include\u0026gt; \u0026lt;!-- 例如 --\u0026gt; \u0026lt;select id=\u0026#34;getEmpByCondition\u0026#34; resultType=\u0026#34;Emp\u0026#34;\u0026gt; select \u0026lt;include refid=\u0026#34;empColumns\u0026#34;\u0026gt;\u0026lt;/include\u0026gt; from t_emp \u0026lt;trim prefix=\u0026#34;where\u0026#34; suffixOverrides=\u0026#34;and\u0026#34;\u0026gt; \u0026lt;if test=\u0026#34;empName != null and empName != \u0026#39;\u0026#39;\u0026#34;\u0026gt; emp_name = #{empName} and \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;age != null and age != \u0026#39;\u0026#39;\u0026#34;\u0026gt; age = #{age} and \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;gender != null and gender != \u0026#39;\u0026#39;\u0026#34;\u0026gt; gender = #{gender} \u0026lt;/if\u0026gt; \u0026lt;/trim\u0026gt; \u0026lt;/select\u0026gt; MyBatis****的缓存\rMyBatis****的一级缓存\r一级缓存是SqlSession级别的，通过同一个SqlSession查询的数据会被缓存，下次查询相同的数据，就会从缓存中直接获取，不会从数据库重新访问\n使一级缓存失效的四种情况：\n不同的SqlSession对应不同的一级缓存 同一个SqlSession但是查询条件不同 同一个SqlSession两次查询期间执行了任何一次增删改操作 同一个SqlSession两次查询期间手动清空了缓存 MyBatis****的二级缓存\r二级缓存是 SqlSessionFactory 级别，通过同一个 SqlSessionFactory 创建的 SqlSession 查询的结果会被缓存；此后若再次执行相同的查询语句，结果就会从缓存中获取\n二级缓存开启的条件：\n在核心配置文件中，设置全局配置属性cacheEnabled=\u0026ldquo;true\u0026rdquo;，默认为true，不需要设置 在映射文件中设置标签 二级缓存必须在SqlSession关闭或提交之后有效 查询的数据所转换的实体类类型必须实现序列化的接口 使二级缓存失效的情况：\n两次查询之间执行了任意的增删改，会使一级和二级缓存同时失效\n二级缓存的相关配置\r看不懂的话 跳过\n在 mapper 配置文件中添加的 cache 标签可以设置一些属性：\neviction属性：缓存回收策略，默认的是 LRU。 LRU（Least Recently Used） – 最近最少使用的：移除最长时间不被使用的对象。 FIFO（First in First out） – 先进先出：按对象进入缓存的顺序来移除它们。 SOFT – 软引用：移除基于垃圾回收器状态和软引用规则的对象。 WEAK – 弱引用：更积极地移除基于垃圾收集器状态和弱引用规则的对象。\n②flushInterval属性：刷新间隔，单位毫秒 默认情况是不设置，也就是没有刷新间隔，缓存仅仅调用语句时刷新\n③size属性：引用数目，正整数 代表缓存最多可以存储多少个对象，太大容易导致内存溢出\n④readOnly属性：只读， true/false true：只读缓存；会给所有调用者返回缓存对象的相同实例。因此这些对象不能被修改。这提供了 很重要的性能优势。 false：读写缓存；会返回缓存对象的拷贝（通过序列化）。这会慢一些，但是安全，因此默认是 false。\nMyBatis****缓存查询的顺序\r先查询二级缓存，因为二级缓存中可能会有其他程序已经查出来的数据，可以拿来直接使用。\n如果二级缓存没有命中，再查询一级缓存\n如果一级缓存也没有命中，则查询数据库\nSqlSession 关闭之后，一级缓存中的数据会写入二级缓存\n整合第三方缓存****EHCache\r添加依赖\n\u0026lt;!-- Mybatis EHCache整合包 --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.mybatis.caches\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mybatis-ehcache\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.2.1\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!-- slf4j日志门面的一个具体实现 --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;ch.qos.logback\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;logback-classic\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.2.3\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; 各jar包功能\n创建EHCache的配置文件ehcache.xml\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;utf-8\u0026#34; ?\u0026gt; \u0026lt;ehcache xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:noNamespaceSchemaLocation=\u0026#34;../config/ehcache.xsd\u0026#34;\u0026gt; \u0026lt;!-- 磁盘保存路径 --\u0026gt; \u0026lt;diskStore path=\u0026#34;D:\\atguigu\\ehcache\u0026#34;/\u0026gt; \u0026lt;defaultCache maxElementsInMemory=\u0026#34;1000\u0026#34;maxElementsOnDisk=\u0026#34;10000000\u0026#34; eternal=\u0026#34;false\u0026#34; overflowToDisk=\u0026#34;true\u0026#34;timeToIdleSeconds=\u0026#34;120\u0026#34; timeToLiveSeconds=\u0026#34;120\u0026#34;diskExpiryThreadIntervalSeconds=\u0026#34;120\u0026#34;memoryStoreEvictionPolicy=\u0026#34;LRU\u0026#34;\u0026gt; \u0026lt;/defaultCache\u0026gt; \u0026lt;/ehcache\u0026gt; 设置二级缓存的类型\n\u0026lt;cache type=\u0026#34;org.mybatis.caches.ehcache.EhcacheCache\u0026#34;/\u0026gt; 加入logback日志\n\u0026lt;?xmlversion=\u0026#34;1.0\u0026#34;encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt;\u0026lt;configurationdebug=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;!--指定日志输出的位置--\u0026gt; \u0026lt;appendername=\u0026#34;STDOUT\u0026#34;class=\u0026#34;ch.qos.logback.core.ConsoleAppender\u0026#34;\u0026gt; \u0026lt;encoder\u0026gt; \u0026lt;!--日志输出的格式--\u0026gt; \u0026lt;!--按照顺序分别是：时间、日志级别、线程名称、打印日志的类、日志主体内容、换行--\u0026gt; \u0026lt;pattern\u0026gt;[%d{HH:mm:ss.SSS}][%-5level][%thread][%logger][%msg]%n\u0026lt;/pattern\u0026gt; \u0026lt;/encoder\u0026gt; \u0026lt;/appender\u0026gt;\u0026lt;!--设置全局日志级别。日志级别按顺序分别是：DEBUG、INFO、WARN、ERROR--\u0026gt; \u0026lt;!--指定任何一个日志级别都只打印当前级别和后面级别的日志。--\u0026gt; \u0026lt;rootlevel=\u0026#34;DEBUG\u0026#34;\u0026gt; \u0026lt;!--指定打印日志的appender，这里通过“STDOUT”引用了前面配置的appender--\u0026gt; \u0026lt;appender-refref=\u0026#34;STDOUT\u0026#34;/\u0026gt; \u0026lt;/root\u0026gt;\u0026lt;!--根据特殊需求指定局部日志级别--\u0026gt; \u0026lt;loggername=\u0026#34;com.atguigu.crowd.mapper\u0026#34;level=\u0026#34;DEBUG\u0026#34;/\u0026gt;\u0026lt;/configuration\u0026gt; EHCache配置文件说明\n麻烦吗？ 兄弟们 麻烦就接着看 最牛逼的要来了 MyBatis的逆向工程YYDS\nMyBatis****的逆向工程\r正向工程：先创建 Java 实体类，由框架负责根据实体类生成数据库表。 Hibernate 是支持正向工\n程的。\n逆向工程：先创建数据库表，由框架负责根据数据库表，反向生成如下资源：\nJava实体类 Mapper接口 Mapper映射文件 创建逆向工程的步骤\r添加依赖和插件\n\u0026lt;!-- 依赖MyBatis核心包 --\u0026gt; \u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.mybatis\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mybatis\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.5.7\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!-- junit测试 --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;junit\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;junit\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;4.12\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;test\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!-- log4j日志 --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;log4j\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;log4j\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.2.17\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;mysql\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mysql-connector-java\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;8.0.16\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.github.pagehelper\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;pagehelper\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;5.2.0\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; \u0026lt;!-- 控制Maven在构建过程中相关配置 --\u0026gt; \u0026lt;build\u0026gt; \u0026lt;!-- 构建过程中用到的插件 --\u0026gt; \u0026lt;plugins\u0026gt; \u0026lt;!-- 具体插件，逆向工程的操作是以构建过程中插件形式出现的 --\u0026gt; \u0026lt;plugin\u0026gt; \u0026lt;groupId\u0026gt;org.mybatis.generator\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mybatis-generator-maven-plugin\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.3.0\u0026lt;/version\u0026gt; \u0026lt;!-- 插件的依赖 --\u0026gt; \u0026lt;dependencies\u0026gt; \u0026lt;!-- 逆向工程的核心依赖 --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.mybatis.generator\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mybatis-generator-core\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.3.2\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!-- MySQL驱动 --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;mysql\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mysql-connector-java\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;8.0.16\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; \u0026lt;/plugin\u0026gt; \u0026lt;/plugins\u0026gt; \u0026lt;/build\u0026gt; 创建MyBatis的核心配置文件 创建逆向工程的配置 文件****文件名必须是：generatorConfig.xml\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt;\u0026lt;!DOCTYPE generatorConfiguration PUBLIC \u0026#34;-//mybatis.org//DTD MyBatis Generator Configuration 1.0//EN\u0026#34; \u0026#34;http://mybatis.org/dtd/mybatis-generator-config_1_0.dtd\u0026#34;\u0026gt;\u0026lt;generatorConfiguration\u0026gt; \u0026lt;!-- targetRuntime: 执行生成的逆向工程的版本 MyBatis3Simple: 生成基本的CRUD（清新简洁版） MyBatis3: 生成带条件的CRUD（奢华尊享版） --\u0026gt; \u0026lt;context id=\u0026#34;DB2Tables\u0026#34; targetRuntime=\u0026#34;MyBatis3\u0026#34;\u0026gt; \u0026lt;!-- 数据库的连接信息 --\u0026gt; \u0026lt;jdbcConnection driverClass=\u0026#34;com.mysql.cj.jdbc.Driver\u0026#34; connectionURL=\u0026#34;jdbc:mysql://localhost:3306/ssm?serverTimezone=UTC\u0026#34; userId=\u0026#34;root\u0026#34; passWord=\u0026#34;\u0026#34;\u0026gt; \u0026lt;!-- 此处修改为password --\u0026gt; \u0026lt;/jdbcConnection\u0026gt; \u0026lt;!-- javaBean的生成策略--\u0026gt; \u0026lt;javaModelGenerator targetPackage=\u0026#34;com.atguigu.mybatis.pojo\u0026#34; targetProject=\u0026#34;.\\src\\main\\java\u0026#34;\u0026gt; \u0026lt;property name=\u0026#34;enableSubPackages\u0026#34; value=\u0026#34;true\u0026#34; /\u0026gt; \u0026lt;property name=\u0026#34;trimStrings\u0026#34; value=\u0026#34;true\u0026#34; /\u0026gt; \u0026lt;/javaModelGenerator\u0026gt; \u0026lt;!-- SQL映射文件的生成策略 --\u0026gt; \u0026lt;sqlMapGenerator targetPackage=\u0026#34;com.atguigu.mybatis.mapper\u0026#34; targetProject=\u0026#34;.\\src\\main\\resources\u0026#34;\u0026gt; \u0026lt;property name=\u0026#34;enableSubPackages\u0026#34; value=\u0026#34;true\u0026#34; /\u0026gt; \u0026lt;/sqlMapGenerator\u0026gt; \u0026lt;!-- Mapper接口的生成策略 --\u0026gt; \u0026lt;javaClientGenerator type=\u0026#34;XMLMAPPER\u0026#34; targetPackage=\u0026#34;com.atguigu.mybatis.mapper\u0026#34; targetProject=\u0026#34;.\\src\\main\\java\u0026#34;\u0026gt; \u0026lt;property name=\u0026#34;enableSubPackages\u0026#34; value=\u0026#34;true\u0026#34; /\u0026gt; \u0026lt;/javaClientGenerator\u0026gt; \u0026lt;!-- 逆向分析的表 --\u0026gt; \u0026lt;!-- tableName设置为*号，可以对应所有表，此时不写domainObjectName --\u0026gt; \u0026lt;!-- domainObjectName属性指定生成出来的实体类的类名 --\u0026gt; \u0026lt;table tableName=\u0026#34;t_emp\u0026#34; domainObjectName=\u0026#34;Emp\u0026#34;/\u0026gt; \u0026lt;table tableName=\u0026#34;t_dept\u0026#34; domainObjectName=\u0026#34;Dept\u0026#34;/\u0026gt; \u0026lt;/context\u0026gt;\u0026lt;/generatorConfiguration\u0026gt; 执行 MBG 插件的generate目标 效果图\n分页插件\r分页插件的使用步骤\r添加依赖\ndependency\u0026gt; \u0026lt;groupId\u0026gt;com.github.pagehelper\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;pagehelper\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;5.2.0\u0026lt;/version\u0026gt;\u0026lt;/dependency\u0026gt; 配置分页插件\n\u0026lt;plugins\u0026gt;\u0026lt;!-- 在MyBatis的核心配置文件中配置插件 --\u0026gt; \u0026lt;!--设置分页插件--\u0026gt; \u0026lt;plugininterceptor=\u0026#34;com.github.pagehelper.PageInterceptor\u0026#34;\u0026gt;\u0026lt;/plugin\u0026gt;\u0026lt;/plugins\u0026gt; 分页插件的使用\r在查询功能之前使用PageHelper.startPage(int pageNum, int pageSize)开启分页功能pageNum：当前页的页码 pageSize：每页显示的条数 在查询获取 list 集合之后 使用 PageInfo pageInfo = new PageInfo\u0026lt;\u0026gt;(List list, int navigatePages)获取分页相关 数据 list：分页之后的数据 navigatePages：导航分页的页码数 分页相关数据 PageInfo{\npageNum=8, pageSize=4, size=2, startRow=29, endRow=30, total=30, pages=8,\nlist=Page{count=true, pageNum=8, pageSize=4, startRow=28, endRow=32, total=30,\npages=8, reasonable=false, pageSizeZero=false},\nprePage=7, nextPage=0, isFirstPage=false, isLastPage=true, hasPreviousPage=true,\nhasNextPage=false, navigatePages=5, navigateFirstPage4, navigateLastPage8,\nnavigatepageNums=[4, 5, 6, 7, 8]\n}\npageNum ：当前页的页码\npageSize ：每页显示的条数\nsize ：当前页显示的真实条数\ntotal ：总记录数\npages ：总页数\nprePage ：上一页的页码\nnextPage ：下一页的页码\nisFirstPage/isLastPage ：是否为第一页 / 最后一页\nhasPreviousPage/hasNextPage ：是否存在上一页 / 下一页\nnavigatePages ：导航分页的页码数\nnavigatepageNums ：导航分页的页码， [1,2,3,4,5]\nMybatis完结撒花 spring 已经看完 准备复习+笔记 2022.10.15/23:59:15\nSpring\rSpring简介\rsping概述\r官网地址：https://spring.io/\nSpring 是最受欢迎的企业级 Java 应用程序开发框架，数以百万的来自世界各地的开发人员使用Spring 框架来创建性能好、易于测试、可重用的代码。\nSpring 框架是一个开源的 Java 平台，它最初是由 Rod Johnson 编写的，并且于 2003 年 6 月首次在 Apache 2.0 许可下发布。\nSpring 是轻量级的框架，其基础版本只有 2 MB 左右的大小。\nSpring 框架的核心特性是可以用于开发任何 Java 应用程序，但是在 Java EE 平台上构建 web 应用程序是需要扩展的。 Spring 框架的目标是使 J2EE 开发变得更容易使用，通过启用基于 POJO编程模型来促进良好的编程实践。\nSpring****家族\r项目列表：https://spring.io/projects\nSpring Framework\rSpring 基础框架，可以视为 Spring 基础设施，基本上任何其他 Spring 项目都是以 Spring Framework为基础的。\nSpring Framework****特性\r非侵入式：使用 Spring Framework 开发应用程序时，Spring 对应用程序本身的结构影响非常小。对领域模型可以做到零污染；对功能性组件也只需要使用几个简单的注解进行标记，完全不会破坏原有结构，反而能将组件结构进一步简化。这就使得基于 Spring Framework 开发应用程序时结构清晰、简洁优雅。 控制反转：IOC——Inversion of Control，翻转资源获取方向。把自己创建资源、向环境索取资源变成环境将资源准备好，我们享受资源注入。（重点） 面向切面编程：AOP——Aspect Oriented Programming，在不修改源代码的基础上增强代码功能。（重点） 容器：Spring IOC 是一个容器，因为它包含并且管理组件对象的生命周期。组件享受到了容器化的管理，替程序员屏蔽了组件创建过程中的大量细节，极大的降低了使用门槛，大幅度提高了开发效率。（理解） 组件化：Spring 实现了使用简单的组件配置组合成一个复杂的应用。在 Spring 中可以使用 XML和 Java 注解组合这些对象。这使得我们可以基于一个个功能明确、边界清晰的组件有条不紊的搭建超大型复杂应用系统。 声明式：很多以前需要编写代码才能实现的功能，现在只需要声明需求即可由框架代为实现。 一站式：在 IOC 和 AOP 的基础上可以整合各种企业应用的开源框架和优秀的第三方类库。而且 Spring 旗下的项目已经覆盖了广泛领域，很多方面的功能性需求可以在 Spring Framework 的基础上全部使用 Spring 来实现。 Spring Framework****五大功能模块\r| 功能模块\n|\n功能介绍\nCore Container\n|\n核心容器，在 Spring 环境下使用任何功能都必须基于 IOC 容器。\n| |\nAOP\u0026amp;Aspects\n|\n面向切面编程。\n| |\nTesting\n|\n提供了对 junit 或 TestNG 测试框架的整合。\n| |\nData Access/Integration\n|\n提供了对数据访问 / 集成的功能。\n| |\nSpring MVC\n|\n提供了面向 Web 应用程序的集成功能。\n|\nIOC\rIOC****容器\rIOC****思想\rIOC：Inversion of Control，翻译过来是反转控制。\n获取资源的传统方式\n自己做饭：买菜、洗菜、择菜、改刀、炒菜，全过程参与，费时费力，必须清楚了解资源创建整个过程中的全部细节且熟练掌握。\n在应用程序中的组件需要获取资源时，传统的方式是组件 主动 的从容器中获取所需要的资源，在这样的模式下开发人员往往需要知道在具体容器中特定资源的获取方式，增加了学习成本，同时降低了开发效率。\n反转控制方式获取资源\n点外卖：下单、等、吃，省时省力，不必关心资源创建过程的所有细节。\n反转控制的思想完全颠覆了应用程序组件获取资源的传统方式：反转了资源的获取方向 —— 改由容器主动的将资源推送给需要的组件，开发人员不需要知道容器是如何创建资源对象的，只需要提供接收资源的方式即可，极大的降低了学习成本，提高了开发的效率。这种行为也称为查找的被动 形式。\nDI\nDI ： Dependency Injection ，翻译过来是 依赖注入 。\nDI 是 IOC 的另一种表述方式：即组件以一些预先定义好的方式（例如： setter 方法）接受来自于容器 的资源注入。相对于IOC 而言，这种表述更直接。\n所以结论是： IOC 就是一种反转控制的思想，而 DI 是对 IOC 的一种具体实现。\nIOC容器在Spring****中的实现\nSpring 的 IOC 容器就是 IOC 思想的一个落地的产品实现。 IOC 容器中管理的组件也叫做 bean 。在创建bean 之前，首先需要创建 IOC 容器。 Spring 提供了 IOC 容器的两种实现方式：\nBeanFactory 这是 IOC 容器的基本实现，是 Spring 内部使用的接口。面向 Spring 本身，不提供给开发人员使用。\nApplicationContext BeanFactory 的子接口，提供了更多高级特性。面向 Spring 的使用者，几乎所有场合都使用 ApplicationContext 而不是底层的 BeanFactory。\nApplicationContext的主要实现类\n| 类型名\n|\n简介\nClassPathXmlApplicationContext\n|\n通过读取类路径下的 XML 格式的配置文件创建 IOC 容器对象\n| |\nFileSystemXmlApplicationContext\n|\n通过文件系统路径读取 XML 格式的配置文件创建 IOC 容器对象\n| |\nConfigurableApplicationContext\n|\nApplicationContext 的子接口，包含一些扩展方法\nrefresh() 和 close() ，让 ApplicationContext 具有启动、\n关闭和刷新上下文的能力。\n| |\nWebApplicationContext\n|\n专门为 Web 应用准备，基于 Web 环境创建 IOC 容器对\n象，并将对象引入存入 ServletContext 域中。\n|\n基于XML管理****bean\r实验一：入门案例\r创建Maven Module\n引入依赖\n\u0026lt;dependencies\u0026gt; \u0026lt;!--基于Maven依赖传递性，导入spring-context依赖即可导入当前所需所有jar包--\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-context\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;5.3.1\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!--junit测试--\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;junit\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;junit\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;4.12\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;test\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt;\u0026lt;/dependencies\u0026gt; 创建类HelloWorld\npublic class HelloWorld { public void sayHello(){ System.out.println(\u0026#34;hello,spring\u0026#34;); } } 创建Spring的配置文件\n在Spring的配置文件中配置bean\n\u0026lt;!-- 配置HelloWorld所对应的bean，即将HelloWorld的对象交给Spring的IOC容器管理 通过bean标签配置IOC容器所管理的bean 属性： id：设置bean的唯一标识 class：设置bean所对应类型的全类名--\u0026gt;\u0026lt;bean id=\u0026#34;helloworld\u0026#34;class=\u0026#34;com.seven.spring.bean.HelloWorld\u0026#34;\u0026gt;\u0026lt;/bean\u0026gt; 创建测试类测试\n@Test public void test(){ //获取IOC容器 ApplicationContext ioc = new ClassPathXmlApplicationContext(\u0026#34;applicationContext.xml\u0026#34;); //获取IOC容器中的bean HelloWorld helloworld = (HelloWorld) ioc.getBean(\u0026#34;helloworld\u0026#34;); helloworld.sayHello(); } 思路\n注意\nSpring 底层默认通过反射技术调用组件类的无参构造器来创建组件对象，这一点需要注意。如果在需要 无参构造器时，没有无参构造器，则会抛出下面的异常：\norg.springframework.beans.factory.BeanCreationException: Error creating bean with name \u0026lsquo;helloworld\u0026rsquo; defined in class path resource [applicationContext.xml]: Instantiation of bean failed; nested exception is org.springframework.beans.BeanInstantiationException: Failed\nto instantiate [com.atguigu.spring.bean.HelloWorld]: No default constructor found; nested exception is java.lang.NoSuchMethodException: com.atguigu.spring.bean.HelloWorld. ()\n实验二：获取bean\r方式一：根据****id获取\n由于 id 属性指定了 bean 的唯一标识，所以根据 bean 标签的 id 属性可以精确获取到一个组件对象。 上个实验中我们使用的就是这种方式。\n方式二：根据类型获取\n@Test public void test(){ //获取IOC容器 ApplicationContext ioc =new ClassPathXmlApplicationContext(\u0026#34;applicationContext.xml\u0026#34;); //获取IOC容器中的bean对象 HelloSpring helloSpring = (HelloSpring) ioc.getBean(HelloSpring.class); helloSpring.seyHello(); } 根据id和类型\n@Test public void test(){ //获取IOC容器 ApplicationContext ioc =new ClassPathXmlApplicationContext(\u0026#34;applicationContext.xml\u0026#34;); //获取IOC容器中的bean对象 HelloSpring helloSpring = (HelloSpring) ioc.getBean(\u0026#34;helloword\u0026#34;,HelloSpring.class); helloSpring.seyHello(); } 注意\n当根据类型获取 bean 时，要求 IOC 容器中指定类型的 bean 有且只能有一个\n当 IOC 容器中一共配置了两个：\n\u0026lt;bean id=\u0026#34;helloworldOne\u0026#34;class=\u0026#34;com.atguigu.spring.bean.HelloWorld\u0026#34;\u0026gt;\u0026lt;/bean\u0026gt;\u0026lt;bean id=\u0026#34;helloworldTwo\u0026#34;class=\u0026#34;com.atguigu.spring.bean.HelloWorld\u0026#34;\u0026gt;\u0026lt;/bean\u0026gt; 根据类型获取时会抛出异常：\norg.springframework.beans.factory.NoUniqueBeanDefinitionException: No qualifying bean of type \u0026lsquo;com.atguigu.spring.bean.HelloWorld\u0026rsquo; available: expected single matching bean but found 2: helloworldOne,helloworldTwo\n扩展\n如果组件类实现了接口，根据接口类型可以获取 bean 吗？\n可以，前提是 bean 唯一\n如果一个接口有多个实现类，这些实现类都配置了 bean ，根据接口类型可以获取 bean 吗？\n不行，因为 bean 不唯一\n结论\n根据类型来获取 bean 时，在满足 bean 唯一性的前提下，其实只是看：\n『对象instanceof 指定的类型』的返回结果，只要返回的是true 就可以认定为和类型匹配，能够获取到。\n实验三：依赖注入之setter注入\r创建学生类Student\npublic class Student { private String name; private int age; private Dept dept; public Student(String name, int age, Dept dept) { this.name = name; this.age = age; this.dept = dept; } public String getName() { return name; } public void setName(String name) { this.name = name; } public int getAge() { return age; } public void setAge(int age) { this.age = age; } public Dept getDept() { return dept; } public void setDept(Dept dept) { this.dept = dept; } @Override public String toString() { return \u0026#34;Student{\u0026#34; + \u0026#34;name=\u0026#39;\u0026#34; + name + \u0026#39;\\\u0026#39;\u0026#39; + \u0026#34;, age=\u0026#34; + age + \u0026#34;, dept=\u0026#34; + dept + \u0026#39;}\u0026#39;; } public Student() { } public Student(String name, int age) { this.name = name; this.age = age; } 配置bean时为属性赋值\n\u0026lt;!-- scope :设置bean的作用域 scope = \u0026#34;prototype/singleton\u0026#34; prototype:(多例) 表示获取该bean所对应的对象都是同一个 singleton:(单例) 表示获取该bean所对应的对象都不是同一个 --\u0026gt; \u0026lt;bean id=\u0026#34;student\u0026#34; class=\u0026#34;com.seven.spring.pojo.Student\u0026#34; scope=\u0026#34;singleton\u0026#34;\u0026gt; \u0026lt;property name=\u0026#34;name\u0026#34; value=\u0026#34;张三\u0026#34;\u0026gt;\u0026lt;/property\u0026gt; \u0026lt;property name=\u0026#34;age\u0026#34; value=\u0026#34;11\u0026#34;\u0026gt;\u0026lt;/property\u0026gt; \u0026lt;/bean\u0026gt; 测试\n@Test public void test() { //bean.xml为配置注入的spring配置文件 ApplicationContext ioc = new ClassPathXmlApplicationContext(\u0026#34;been.xml\u0026#34;); Student student = ioc.getBean(\u0026#34;student\u0026#34;, Student.class); System.out.println(student); } 2022.10.16 14:54:25 休息休息\r#### **实验四：依赖注入之构造器注入**\r1. **在Student类中添加有参构造**\r```java\rpublic Student(Integer id,String name,Integer age,String sex){ this.id=id; this.name=name; this.age=age; this.sex=sex;}\r```\r2. **配置bean**\r```XML\r\u0026lt;bean id=\u0026quot;studentTwo\u0026quot;class=\u0026quot;com.atguigu.spring.bean.Student\u0026quot;\u0026gt; \u0026lt;!-- constructor-arg标签还有两个属性可以进一步描述构造器参数： index属性：指定参数所在位置的索引（从0开始） name属性：指定参数名 --\u0026gt; \u0026lt;constructor-arg value=\u0026quot;1002\u0026quot;\u0026gt;\u0026lt;/constructor-arg\u0026gt; \u0026lt;constructor-arg value=\u0026quot;李四\u0026quot;\u0026gt;\u0026lt;/constructor-arg\u0026gt; \u0026lt;constructor-arg value=\u0026quot;33\u0026quot;\u0026gt;\u0026lt;/constructor-arg\u0026gt; \u0026lt;constructor-arg value=\u0026quot;女\u0026quot;\u0026gt;\u0026lt;/constructor-arg\u0026gt;\u0026lt;/bean\u0026gt;\r```\r3. **测试**\r```java\r@Testpublic void testDIBySet(){ ApplicationContextac= new ClassPathXmlApplicationContext(\u0026quot;spring-di.xml\u0026quot;); StudentstudentOne = ac.getBean(\u0026quot;studentTwo\u0026quot;,Student.class); System.out.println(studentOne);}\r```\r#### **实验五：特殊值处理**\r1. **字面量赋值**\r\u0026gt; 什么是字面量？\r\u0026gt;\r\u0026gt; int a = 10;\r\u0026gt;\r\u0026gt; 声明一个变量 a ，初始化为 10 ，此时 a 就不代表字母 a 了，而是作为一个变量的名字。当我们引用 a\r\u0026gt;\r\u0026gt; 的时候，我们实际上拿到的值是 10 。\r\u0026gt;\r\u0026gt; 而如果 a 是带引号的： 'a' ，那么它现在不是一个变量，它就是代表 a 这个字母本身，这就是字面\r\u0026gt;\r\u0026gt; 量。所以字面量没有引申含义，就是我们看到的这个数据本身。\r```XML\r\u0026lt;!--使用value属性给bean的属性赋值时，Spring会把value属性的值看做字面量--\u0026gt;\u0026lt;property name=\u0026quot;name\u0026quot; value=\u0026quot;张三\u0026quot;/\u0026gt;\r```\r2. **null****值**\r```XML\r\u0026lt;property name=\u0026quot;name\u0026quot;\u0026gt; \u0026lt;null/\u0026gt;\u0026lt;/property\u0026gt;\r```\r\u0026gt; 注意：\r\u0026gt;\r\u0026gt; ```XML\r\u0026gt; \u0026lt;property name=\u0026quot;name\u0026quot; value=\u0026quot;null\u0026quot;\u0026gt;\u0026lt;/property\u0026gt;\r\u0026gt; ```\r\u0026gt;\r\u0026gt; 以上写法，为 name 所赋的值是字符串 null\r3. **xml****实体**\r```XML\r\u0026lt;!--小于号在XML文档中用来定义标签的开始，不能随便使用--\u0026gt;\u0026lt;!--解决方案一：使用XML实体来代替--\u0026gt;\u0026lt;property name=\u0026quot;expression\u0026quot; value=\u0026quot;a\u0026amp;lt;b\u0026quot;/\u0026gt;\r```\r4. **CDATA节**\r```XML\r\u0026lt;property name=\u0026quot;expression\u0026quot;\u0026gt; \u0026lt;!--解决方案二：使用CDATA节--\u0026gt; \u0026lt;!--CDATA中的C代表Character，是文本、字符的含义，CDATA就表示纯文本数据--\u0026gt; \u0026lt;!--XML解析器看到CDATA节就知道这里是纯文本，就不会当作XML标签或属性来解析--\u0026gt; \u0026lt;!--所以CDATA节中写什么符号都随意--\u0026gt; \u0026lt;value\u0026gt;\u0026lt;![CDATA[a\u0026lt;b]]\u0026gt;\u0026lt;/value\u0026gt;\u0026lt;/property\u0026gt;\r```\r#### **实验六：为类类型属性赋值**\r1. **创建班级类Clazz**\r```java\rpublic class Clazz { private Integer clazzId; private String clazzName; public Integer getClazzId() { return clazzId; } public void setClazzId(Integer clazzId) { this.clazzId = clazzId; } public String getClazzName() { return clazzName; } public void setClazzName(String clazzName) { this.clazzName = clazzName; } @Override public String toString() { return \u0026quot;Clazz{\u0026quot; + \u0026quot;clazzId=\u0026quot; + clazzId + \u0026quot;, clazzName='\u0026quot; + clazzName + '\\'' + '}'; } public Clazz() { } public Clazz(Integer clazzId, String clazzName) { this.clazzId = clazzId; this.clazzName = clazzName; }}\r```\r2. **修改****Student类**\r在 Student 类中添加以下代码：\r```java\rprivate Clazz clazz;public Clazz getClazz() {return clazz;}public void setClazz(Clazz clazz) {this.clazz = clazz;}\r```\r3. **方式一：引用外部已声明的bean**\r```XML\r\u0026lt;bean id=\u0026quot;clazzOne\u0026quot; class=\u0026quot;com.atguigu.spring.bean.Clazz\u0026quot;\u0026gt;\u0026lt;property name=\u0026quot;clazzId\u0026quot; value=\u0026quot;1111\u0026quot;\u0026gt;\u0026lt;/property\u0026gt;\u0026lt;property name=\u0026quot;clazzName\u0026quot; value=\u0026quot;财源滚滚班\u0026quot;\u0026gt;\u0026lt;/property\u0026gt;\u0026lt;/bean\u0026gt;\r```\r为 Student 中的 clazz 属性赋值：\r```XML\r\u0026lt;bean id=\u0026quot;studentFour\u0026quot; class=\u0026quot;com.atguigu.spring.bean.Student\u0026quot;\u0026gt;\u0026lt;property name=\u0026quot;id\u0026quot; value=\u0026quot;1004\u0026quot;\u0026gt;\u0026lt;/property\u0026gt;\u0026lt;property name=\u0026quot;name\u0026quot; value=\u0026quot;赵六\u0026quot;\u0026gt;\u0026lt;/property\u0026gt;\u0026lt;property name=\u0026quot;age\u0026quot; value=\u0026quot;26\u0026quot;\u0026gt;\u0026lt;/property\u0026gt;\u0026lt;property name=\u0026quot;sex\u0026quot; value=\u0026quot;女\u0026quot;\u0026gt;\u0026lt;/property\u0026gt;\u0026lt;!-- ref属性：引用IOC容器中某个bean的id，将所对应的bean为属性赋值 --\u0026gt;\u0026lt;property name=\u0026quot;clazz\u0026quot; ref=\u0026quot;clazzOne\u0026quot;\u0026gt;\u0026lt;/property\u0026gt;\u0026lt;/bean\u0026gt;\r```\r错误演示：\r```XML\r\u0026lt;bean id=\u0026quot;studentFour\u0026quot; class=\u0026quot;com.atguigu.spring.bean.Student\u0026quot;\u0026gt;\u0026lt;property name=\u0026quot;id\u0026quot; value=\u0026quot;1004\u0026quot;\u0026gt;\u0026lt;/property\u0026gt;\u0026lt;property name=\u0026quot;name\u0026quot; value=\u0026quot;赵六\u0026quot;\u0026gt;\u0026lt;/property\u0026gt;\u0026lt;property name=\u0026quot;age\u0026quot; value=\u0026quot;26\u0026quot;\u0026gt;\u0026lt;/property\u0026gt;\u0026lt;property name=\u0026quot;sex\u0026quot; value=\u0026quot;女\u0026quot;\u0026gt;\u0026lt;/property\u0026gt;\u0026lt;property name=\u0026quot;clazz\u0026quot; value=\u0026quot;clazzOne\u0026quot;\u0026gt;\u0026lt;/property\u0026gt;\u0026lt;/bean\u0026gt;\r```\r\u0026gt; 如果错把 ref 属性写成了 value 属性，会抛出异常： Caused by: java.lang.IllegalStateException:\r\u0026gt;\r\u0026gt; Cannot convert value of type 'java.lang.String' to required type\r\u0026gt;\r\u0026gt; 'com.atguigu.spring.bean.Clazz' for property 'clazz': no matching editors or conversion\r\u0026gt;\r\u0026gt; strategy found\r\u0026gt;\r\u0026gt; 意思是不能把 String 类型转换成我们要的 Clazz 类型，说明我们使用 value 属性时， Spring 只把这个属性看做一个普通的字符串，不会认为这是一个bean 的 id ，更不会根据它去找到 bean 来赋值\r4. **方式二：内部****bean**\r```XML\r\u0026lt;bean id=\u0026quot;studentFour\u0026quot; class=\u0026quot;com.atguigu.spring.bean.Student\u0026quot;\u0026gt;\u0026lt;property name=\u0026quot;id\u0026quot; value=\u0026quot;1004\u0026quot;\u0026gt;\u0026lt;/property\u0026gt;\u0026lt;property name=\u0026quot;name\u0026quot; value=\u0026quot;赵六\u0026quot;\u0026gt;\u0026lt;/property\u0026gt;\u0026lt;property name=\u0026quot;age\u0026quot; value=\u0026quot;26\u0026quot;\u0026gt;\u0026lt;/property\u0026gt;\u0026lt;property name=\u0026quot;sex\u0026quot; value=\u0026quot;女\u0026quot;\u0026gt;\u0026lt;/property\u0026gt;\u0026lt;property name=\u0026quot;clazz\u0026quot;\u0026gt;\u0026lt;!-- 在一个bean中再声明一个bean就是内部bean --\u0026gt;\u0026lt;!-- 内部bean只能用于给属性赋值，不能在外部通过IOC容器获取，因此可以省略id属性 --\u0026gt;\u0026lt;bean id=\u0026quot;clazzInner\u0026quot; class=\u0026quot;com.atguigu.spring.bean.Clazz\u0026quot;\u0026gt;\u0026lt;property name=\u0026quot;clazzId\u0026quot; value=\u0026quot;2222\u0026quot;\u0026gt;\u0026lt;/property\u0026gt;\u0026lt;property name=\u0026quot;clazzName\u0026quot; value=\u0026quot;远大前程班\u0026quot;\u0026gt;\u0026lt;/property\u0026gt;\u0026lt;/bean\u0026gt;\u0026lt;/property\u0026gt;\u0026lt;/bean\u0026gt;\r```\r5. **方式三：级联属性赋值**\r```XML\r\u0026lt;bean id=\u0026quot;studentFour\u0026quot; class=\u0026quot;com.atguigu.spring.bean.Student\u0026quot;\u0026gt;\u0026lt;property name=\u0026quot;id\u0026quot; value=\u0026quot;1004\u0026quot;\u0026gt;\u0026lt;/property\u0026gt;\u0026lt;property name=\u0026quot;name\u0026quot; value=\u0026quot;赵六\u0026quot;\u0026gt;\u0026lt;/property\u0026gt;\u0026lt;property name=\u0026quot;age\u0026quot; value=\u0026quot;26\u0026quot;\u0026gt;\u0026lt;/property\u0026gt;\u0026lt;property name=\u0026quot;sex\u0026quot; value=\u0026quot;女\u0026quot;\u0026gt;\u0026lt;/property\u0026gt;\u0026lt;!-- 一定先引用某个bean为属性赋值，才可以使用级联方式更新属性 --\u0026gt;\u0026lt;property name=\u0026quot;clazz\u0026quot; ref=\u0026quot;clazzOne\u0026quot;\u0026gt;\u0026lt;/property\u0026gt;\u0026lt;property name=\u0026quot;clazz.clazzId\u0026quot; value=\u0026quot;3333\u0026quot;\u0026gt;\u0026lt;/property\u0026gt;\u0026lt;property name=\u0026quot;clazz.clazzName\u0026quot; value=\u0026quot;最强王者班\u0026quot;\u0026gt;\u0026lt;/property\u0026gt;\u0026lt;/bean\u0026gt;\r```\r### 各位1024节快乐 2022.10.24 22:10:20 这次断更主要是因为最近参加考试事情有点多 等我闲下来马上更新\r#### **实验七：为数组类型属性赋值**\r1. **修改****Student****类** 在Student类中添加以下代码：\r```java\rprivate String[] hobbies;public String[] getHobbies() {return hobbies;}public void setHobbies(String[] hobbies) {this.hobbies = hobbies;}\r```\r2. **配bean**\r```XML\r\u0026lt;bean id=\u0026quot;student\u0026quot; class=\u0026quot;com.seven.spring.pojo.Student\u0026quot; scope=\u0026quot;singleton\u0026quot;\u0026gt; \u0026lt;property name=\u0026quot;name\u0026quot; value=\u0026quot;张三\u0026quot;\u0026gt;\u0026lt;/property\u0026gt; \u0026lt;property name=\u0026quot;age\u0026quot; value=\u0026quot;11\u0026quot;\u0026gt;\u0026lt;/property\u0026gt; \u0026lt;/bean\u0026gt; \u0026lt;bean id=\u0026quot;studentFour\u0026quot; class=\u0026quot;com.seven.spring.pojo.Student\u0026quot;\u0026gt; \u0026lt;property name=\u0026quot;name\u0026quot; value=\u0026quot;赵六\u0026quot;\u0026gt;\u0026lt;/property\u0026gt; \u0026lt;property name=\u0026quot;age\u0026quot; value=\u0026quot;26\u0026quot;\u0026gt;\u0026lt;/property\u0026gt; \u0026lt;property name=\u0026quot;dept\u0026quot; ref=\u0026quot;dept\u0026quot;\u0026gt;\u0026lt;/property\u0026gt; \u0026lt;!-- ref属性：引用IOC容器中某个bean的id，将所对应的bean为属性赋值 --\u0026gt; \u0026lt;property name=\u0026quot;hobbies\u0026quot;\u0026gt; \u0026lt;array\u0026gt; \u0026lt;value\u0026gt;抽烟\u0026lt;/value\u0026gt; \u0026lt;value\u0026gt;喝酒\u0026lt;/value\u0026gt; \u0026lt;value\u0026gt;烫头\u0026lt;/value\u0026gt; \u0026lt;/array\u0026gt; \u0026lt;/property\u0026gt; \u0026lt;/bean\u0026gt; \u0026lt;bean id=\u0026quot;dept\u0026quot; class=\u0026quot;com.seven.spring.pojo.Dept\u0026quot;\u0026gt; \u0026lt;property name=\u0026quot;className\u0026quot; value=\u0026quot;软件212\u0026quot;\u0026gt;\u0026lt;/property\u0026gt; \u0026lt;/bean\u0026gt;\r```\r3. **测试类**\r```java\r@Test public void testScope(){ ApplicationContext ioc = new ClassPathXmlApplicationContext(\u0026quot;spring-scope.xml\u0026quot;); Student bean = ioc.getBean(\u0026quot;studentFour\u0026quot;,Student.class);// 我重写了tostring方法所以可以直接输出 System.out.println(bean); }\r```\r4. **效果**![](https://i-blog.csdnimg.cn/blog_migrate/a2a4dba9c59c73bf7d596096bffe61c8.png)\r#### 实验八：**引入外部属性文件**\r1. **加入依赖**\r```XML\r\u0026lt;!-- MySQL驱动 --\u0026gt;\u0026lt;dependency\u0026gt;\u0026lt;groupId\u0026gt;mysql\u0026lt;/groupId\u0026gt;\u0026lt;artifactId\u0026gt;mysql-connector-java\u0026lt;/artifactId\u0026gt;\u0026lt;version\u0026gt;8.0.16\u0026lt;/version\u0026gt;\u0026lt;/dependency\u0026gt;\u0026lt;!-- 数据源 --\u0026gt;\u0026lt;dependency\u0026gt;\u0026lt;groupId\u0026gt;com.alibaba\u0026lt;/groupId\u0026gt;\u0026lt;artifactId\u0026gt;druid\u0026lt;/artifactId\u0026gt;\u0026lt;version\u0026gt;1.0.31\u0026lt;/version\u0026gt;\u0026lt;/dependency\u0026gt;\r```\r2. **创建外部属性文件**\r![](https://i-blog.csdnimg.cn/blog_migrate/ee1a20011d907c06e8eb4799dde40031.png)\r```XML\rjdbc.driver=com.mysql.cj.jdbc.Driverjdbc.url=jdbc:mysql://localhost:13306/ssm?serverTimezone=UTCjdbc.username=rootjdbc.password=asd1230.\r```\r**这是配置自己的数据库信息**\r3. **引入并且配置bean**\r```XML\r\u0026lt;!--引入属性文件--\u0026gt;\u0026lt;context:property-placeholder location=\u0026quot;jdbc.properties\u0026quot;\u0026gt;\u0026lt;/context:property-placeholder\u0026gt; \u0026lt;!--给数据源配置bean--\u0026gt;\u0026lt;bean id=\u0026quot;dateSource\u0026quot; class=\u0026quot;com.alibaba.druid.pool.DruidDataSource\u0026quot;\u0026gt; \u0026lt;property name=\u0026quot;driverClassName\u0026quot; value=\u0026quot;${jdbc.driver}\u0026quot;\u0026gt;\u0026lt;/property\u0026gt; \u0026lt;property name=\u0026quot;url\u0026quot; value=\u0026quot;${jdbc.url}\u0026quot;\u0026gt;\u0026lt;/property\u0026gt; \u0026lt;property name=\u0026quot;username\u0026quot; value=\u0026quot;${jdbc.username}\u0026quot;\u0026gt;\u0026lt;/property\u0026gt; \u0026lt;property name=\u0026quot;password\u0026quot; value=\u0026quot;${jdbc.password}\u0026quot;\u0026gt;\u0026lt;/property\u0026gt;\u0026lt;/bean\u0026gt;\r```\r4. 测试类\r```java\r@Test public void testConn() throws SQLException { ApplicationContext ioc = new ClassPathXmlApplicationContext(\u0026quot;spring-datasource.xml\u0026quot;); DruidDataSource bean = ioc.getBean(DruidDataSource.class); System.out.println(bean.getConnection()); }\r```\r**注意xml名字改成自己写的，如果连接失败看一下是不是自己的信息填错了或者数据库开了没**\r5. 输出结果![](https://i-blog.csdnimg.cn/blog_migrate/337393f720748c5b34d8b23e9f90cdbe.png)\r#### **实验九：bean的作用域**\r1. **概念** 在Spring中可以通过配置bean标签的scope属性来指定bean的作用域范围，各取值含义参加下表：\r|\r**取值**\r| 含义 |\r**创建对象的时机**\r|\r| --- | --- | --- |\r|\rsingleton （默认）\r|\r在 IOC 容器中，这个 bean 的对象始终为单实例\r|\rIOC 容器初始化时\r|\r|\rprototype\r|\r这个 bean 在 IOC 容器中有多个实例\r|\r获取 bean 时\r|\r如果是在 WebApplicationContext 环境下还会有另外两个作用域（但不常用）：\r|\r**取值**\r| 含义 |\r| --- | --- |\r|\rrequest\r|\r在一个请求范围内有效\r|\r|\rsession\r|\r在一个会话范围内有效\r|\r2. **创建类User**\r```java\rpublic class User {private Integer id;private String username;private String password;private Integer age;public User() {}public User(Integer id, String username, String password, Integer age) {this.id = id;this.username = username;this.password = password;this.age = age;}public Integer getId() {return id;}public void setId(Integer id) {this.id = id;}public String getUsername() {return username;}public void setUsername(String username) {this.username = username;}public String getPassword() {return password;}public void setPassword(String password) {this.password = password;}public Integer getAge() {return age;}public void setAge(Integer age) {this.age = age;}@Overridepublic String toString() {return \u0026quot;User{\u0026quot; +\u0026quot;id=\u0026quot; + id +\u0026quot;, username='\u0026quot; + username + '\\'' +\u0026quot;, password='\u0026quot; + password + '\\'' +\u0026quot;, age=\u0026quot; + age +'}';}}\r```\r3. **配置** **bean**\r```XML\r\u0026lt;!-- scope :设置bean的作用域 scope = \u0026quot;prototype/singleton\u0026quot; singleton:(单例) 表示获取该bean所对应的对象都是同一个 prototype:(多例) 表示获取该bean所对应的对象都不是同一个 --\u0026gt;\u0026lt;bean id=\u0026quot;student\u0026quot; class=\u0026quot;com.seven.spring.pojo.Student\u0026quot; scope=\u0026quot;singleton\u0026quot;\u0026gt; \u0026lt;property name=\u0026quot;name\u0026quot; value=\u0026quot;张三\u0026quot;\u0026gt;\u0026lt;/property\u0026gt; \u0026lt;property name=\u0026quot;age\u0026quot; value=\u0026quot;11\u0026quot;\u0026gt;\u0026lt;/property\u0026gt;\u0026lt;/bean\u0026gt;\r```\r4. **测试**\r```java\r@Test public void testScope(){ ApplicationContext ioc = new ClassPathXmlApplicationContext(\u0026quot;spring-scope.xml\u0026quot;); Student bean = ioc.getBean(Student.class); Student bean1 = ioc.getBean(Student.class); System.out.println(bean); System.out.println(bean1); System.out.println(bean==bean1); }\r```\r证明容器获取的同一个对象单例模式\r![](https://i-blog.csdnimg.cn/blog_migrate/9da3d4a58ae1d112d4f79f684c722763.png)\r如果把配置文件中scope的值更改为prototype则效果如下\r![](https://i-blog.csdnimg.cn/blog_migrate/23fb1dfd3fdda1626813ab75835a4f9b.png)\r###\r###\r###\r2022年11月21日0:25:00\r因为学校所在地疫情较为严重 所以学校让请假回家，今天刚到家，尽量每天持续不断的更新！一起加油！！\r实验十**：bean****的生命周期**\r具体的生命周期过程\nbean 对象创建（调用无参构造器）\n给bean对象设置属性\nbean对象初始化之前操作（由bean的后置处理器负责）\nbean对象初始化（需在配置bean时指定初始化方法）\nbean对象初始化之后操作（由bean的后置处理器负责）\nbean对象就绪可以使用\nbean对象销毁（需在配置bean时指定销毁方法）\nIOC容器关闭\n修改类User\npublic class User {private Integer id;private String username;private String password;private Integer age;public User() {System.out.println(\u0026#34;生命周期：1、创建对象\u0026#34;);}public User(Integer id, String username, String password, Integer age) {this.id = id;this.username = username;this.password = password;this.age = age;}public Integer getId() {return id;}public void setId(Integer id) {System.out.println(\u0026#34;生命周期：2、依赖注入\u0026#34;);this.id = id;}public String getUsername() {return username;}public void setUsername(String username) {this.username = username;}public String getPassword() {return password;}public void setPassword(String password) {this.password = password;}public Integer getAge() {return age;}public void setAge(Integer age) {this.age = age;}public void initMethod(){System.out.println(\u0026#34;生命周期：3、初始化\u0026#34;);}public void destroyMethod(){System.out.println(\u0026#34;生命周期：5、销毁\u0026#34;);}@Overridepublic String toString() {return \u0026#34;User{\u0026#34; +\u0026#34;id=\u0026#34; + id +\u0026#34;, username ==\u0026#39;\u0026#34; + username + \u0026#39;\\\u0026#39;\u0026#39; +\u0026#34;, password ==\u0026#39;\u0026#34; + password + \u0026#39;\\\u0026#39;\u0026#39; +\u0026#34;, age=\u0026#34; + age +\u0026#39;}\u0026#39;;}} 注意其中的initMethod()和destroyMethod()，可以通过配置bean指定为初始化和销毁的方法\n配置 bean\n\u0026lt;!-- 使用init-method属性指定初始化方法 --\u0026gt;\u0026lt;!-- 使用destroy-method属性指定销毁方法 --\u0026gt;\u0026lt;bean class=\u0026#34;com.atguigu.bean.User\u0026#34; scope=\u0026#34;prototype\u0026#34; init-method=\u0026#34;initMethod\u0026#34;destroy-method=\u0026#34;destroyMethod\u0026#34;\u0026gt;\u0026lt;property name=\u0026#34;id\u0026#34; value=\u0026#34;1001\u0026#34;\u0026gt;\u0026lt;/property\u0026gt;\u0026lt;property name=\u0026#34;username\u0026#34; value=\u0026#34;admin\u0026#34;\u0026gt;\u0026lt;/property\u0026gt;\u0026lt;property name=\u0026#34;password\u0026#34; value=\u0026#34;123456\u0026#34;\u0026gt;\u0026lt;/property\u0026gt;\u0026lt;property name=\u0026#34;age\u0026#34; value=\u0026#34;23\u0026#34;\u0026gt;\u0026lt;/property\u0026gt;\u0026lt;/bean\u0026gt; 测试\n@Testpublic void testLife(){ClassPathXmlApplicationContext ac = newClassPathXmlApplicationContext(\u0026#34;spring-lifecycle.xml\u0026#34;);User bean = ac.getBean(User.class);System.out.println(\u0026#34;生命周期：4、通过IOC容器获取bean并使用\u0026#34;);ac.close();} bean的后置处理器\nbean 的后置处理器会在生命周期的初始化前后添加额外的操作，需要实现 BeanPostProcessor 接口，且配置到 IOC 容器中，需要注意的是， bean 后置处理器不是单独针对某一个 bean 生效，而是针对 IOC 容 器中所有 bean 都会执行\n创建 bean 的后置处理器：\npackage com.atguigu.spring.process;import org.springframework.beans.BeansException;import org.springframework.beans.factory.config.BeanPostProcessor;public class MyBeanProcessor implements BeanPostProcessor {@Overridepublic Object postProcessBeforeInitialization(Object bean, String beanName)throws BeansException {System.out.println(\u0026#34;☆☆☆\u0026#34; + beanName + \u0026#34; = \u0026#34; + bean);return bean;}@Overridepublic Object postProcessAfterInitialization(Object bean, String beanName)throws BeansException {System.out.println(\u0026#34;★★★\u0026#34; + beanName + \u0026#34; = \u0026#34; + bean);return bean;}} 在 IOC 容器中配置后置处理器：\n\u0026lt;!-- bean的后置处理器要放入IOC容器才能生效 --\u0026gt;\u0026lt;bean id=\u0026#34;myBeanProcessor\u0026#34; class=\u0026#34;com.atguigu.spring.process.MyBeanProcessor\u0026#34;/\u0026gt; **实验十三：**FactoryBean\r简介\nFactoryBean 是 Spring 提供的一种整合第三方框架的常用机制。和普通的 bean 不同，配置一个FactoryBean 类型的 bean ，在获取 bean 的时候得到的并不是 class 属性中配置的这个类的对象，而是 getObject() 方法的返回值。通过这种机制， Spring 可以帮我们把复杂组件创建的详细过程和繁琐细节都 屏蔽起来，只把最简洁的使用界面展示给我们。\n将来我们整合 Mybatis 时， Spring 就是通过 FactoryBean 机制来帮我们创建 SqlSessionFactory 对象的。\n创建类****UserFactoryBean\npublic class UserFactoryBean implements FactoryBean\u0026lt;User\u0026gt; {@Overridepublic User getObject() throws Exception {return new User();}@Overridepublic Class\u0026lt;?\u0026gt; getObjectType() {return User.class;}} 配置 bean\n\u0026lt;bean id=\u0026#34;user\u0026#34; class=\u0026#34;com.atguigu.bean.UserFactoryBean\u0026#34;\u0026gt;\u0026lt;/bean\u0026gt; 测试\n@Testpublic void testUserFactoryBean(){//获取IOC容器ApplicationContext ac = new ClassPathXmlApplicationContext(\u0026#34;springfactorybean.xml\u0026#34;);User user = (User) ac.getBean(\u0026#34;user\u0026#34;);System.out.println(user);} 实验十四：基于xml的自动装配\r自动装配： 根据指定的策略，在IOC容器中匹配某一个bean，自动为指定的bean中所依赖的类类型或接口类型属性赋值\n场景模拟\n创建类 UserController\npublic class UserController {private UserService userService;public void setUserService(UserService userService) {this.userService = userService;}public void saveUser(){userService.saveUser();}} 创建接口 UserService\npublic interface UserService {void saveUser();} 创建类 UserServiceImpl 实现接口 UserService\npublic class UserServiceImpl implements UserService {private UserDao userDao;public void setUserDao(UserDao userDao) {this.userDao = userDao;}@Overridepublic void saveUser() {userDao.saveUser();}} 创建接口 UserDao\npublic interface UserDao {void saveUser();} 创建类 UserDaoImpl 实现接口UserDao\npublic class UserDaoImpl implements UserDao {@Overridepublic void saveUser() {System.out.println(\u0026#34;保存成功\u0026#34;);}} 配置 bean\n使用 bean 标签的 autowire 属性设置自动装配效果\n自动装配方式：byType\nbyType ：根据类型匹配 IOC 容器中的某个兼容类型的 bean ，为属性自动赋值\n若在 IOC 中，没有任何一个兼容类型的 bean 能够为属性赋值，则该属性不装配，即值为默认值 null\n若在 IOC 中，有多个兼容类型的 bean 能够为属性赋值，则抛出异常\nNoUniqueBeanDefinitionException\n\u0026lt;bean id=\u0026#34;userController\u0026#34;class=\u0026#34;com.atguigu.autowire.xml.controller.UserController\u0026#34; autowire=\u0026#34;byType\u0026#34;\u0026gt;\u0026lt;/bean\u0026gt;\u0026lt;bean id=\u0026#34;userService\u0026#34;class=\u0026#34;com.atguigu.autowire.xml.service.impl.UserServiceImpl\u0026#34; autowire=\u0026#34;byType\u0026#34;\u0026gt;\u0026lt;/bean\u0026gt;\u0026lt;bean id=\u0026#34;userDao\u0026#34; class=\u0026#34;com.atguigu.autowire.xml.dao.impl.UserDaoImpl\u0026#34;\u0026gt;\u0026lt;/bean\u0026gt; 自动装配方式： byName\nbyName ：将自动装配的属性的属性名，作为 bean 的 id 在 IOC 容器中匹配相对应的 bean 进行赋值\n\u0026lt;bean id=\u0026#34;userController\u0026#34;class=\u0026#34;com.atguigu.autowire.xml.controller.UserController\u0026#34; autowire=\u0026#34;byName\u0026#34;\u0026gt;\u0026lt;/bean\u0026gt;\u0026lt;bean id=\u0026#34;userService\u0026#34;class=\u0026#34;com.atguigu.autowire.xml.service.impl.UserServiceImpl\u0026#34; autowire=\u0026#34;byName\u0026#34;\u0026gt;\u0026lt;/bean\u0026gt;\u0026lt;bean id=\u0026#34;userServiceImpl\u0026#34;class=\u0026#34;com.atguigu.autowire.xml.service.impl.UserServiceImpl\u0026#34; autowire=\u0026#34;byName\u0026#34;\u0026gt;\u0026lt;/bean\u0026gt;\u0026lt;bean id=\u0026#34;userDao\u0026#34; class=\u0026#34;com.atguigu.autowire.xml.dao.impl.UserDaoImpl\u0026#34;\u0026gt;\u0026lt;/bean\u0026gt;\u0026lt;bean id=\u0026#34;userDaoImpl\u0026#34; class=\u0026#34;com.atguigu.autowire.xml.dao.impl.UserDaoImpl\u0026#34;\u0026gt;\u0026lt;/bean\u0026gt; 测试\n@Testpublic void testAutoWireByXML(){ApplicationContext ac = new ClassPathXmlApplicationContext(\u0026#34;autowirexml.xml\u0026#34;);UserController userController = ac.getBean(UserController.class);userController.saveUser();} 通常来讲 学完springboot后xml的使用较少\r基于注解管理bean\r1. 标记与扫描\r注解\r理解为备注，具体功能为让框架检测到标注的位置，并执行注解的类型来执行。注解本身不能执行\n2. 扫描\rspring需要通过扫描来查看注解的位置进而执行。\n3.创建Maven Module\r\u0026lt;dependencies\u0026gt; \u0026lt;!--基于Maven依赖传递性，导入spring-context依赖即可导入当前所需所有jar包--\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-context\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;5.3.1\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt;\u0026lt;!--junit测试--\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;junit\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;junit\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;4.12\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;test\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt;\u0026lt;/dependencies\u0026gt; 4.创建spring配置文件\r5.标识组件的常用注解\r@Component：将类标识为普通组件 （相对用的较少类似@bean） @bean ：将方法标记为一个spring组件 @Controller：将类标识为控制层组件 @Service：将类标识为业务层组件 @Repository：将类标识为持久层组件 通过查看源码我们得知，@Controller、@Service、@Repository这三个注解只是在@Component注解的基础上起了三个新的名字。\n注意：虽然它们本质上一样，但是为了代码的可读性，为了程序结构严谨我们肯定不能随便胡乱标记\n2. 基于注解的自动装配\r@Autowired注解（自动装配）\n在成员变量上直接标记@Autowired注解即可完成自动装配，不需要提供setXxx()方法。以后我们在项目中的正式用法就是这样。\n@Controllerpublic class UserController { @Autowiredprivate UserService userService; public void saveUser(){ userService.saveUser(); }} 具体流程为在spring容器中搜索UserService的bean（当然要给UserService这个类加上注解不然会扫描不到）然后注入给当前标注的属性\nAOP\r概述:\rAOP （ Aspect Oriented Programming ）是一种设计思想，是软件设计领域中的面向切面编程，它是面向对象编程的一种补充和完善，它以通过预编译方式和运行期动态代理方式实现在不修改源代码的情况下给程序动态统一添加额外功能的一种技术。 (简单来说就是在不修改源码的情况下添加新的功能类似外挂) 比如：拦截器\n相关术语\r横切关注点\n从每个方法中抽取出来的同一类非核心业务。在同一个项目中，我们可以使用多个横切关注点对相关方法进行多个不同方面的增强。\n这个概念不是语法层面天然存在的，而是根据附加功能的逻辑上的需要：有十个附加功能，就有十个横切关注点。\n（对哪些方法进行拦截，拦截后怎么处理，这些关注点称之为横切关注点 ）\nAdvice通知\nAOP在特定的切入点上执行的增强处理，有before（前置）、after（后置）、afterReturning（最终）、afterThrowing（异常）、around（环绕）。\nAspect（切面）\n通常是一个类，里面可以定义切入点和通知。\nJoinPoint（连接点）\n程序执行过程中明确的点，一般是方法的调用，被拦截到的点。因为Spring只支持方法类型的连接点，所以在Spring中连接点指的就是被拦截到的方法，实际上连接点还可以是字段或者构造器。\nPointcut（切入点）\n带有通知的连接点，在程序中主要体现在书写切入点表达式。\n目标对象（Target Object）\n包含连接点的对象，也被称作被通知或被代理对象，POJO。\nAOP代理（AOP Proxy）\nAOP框架创建的对象，代理就是目标对象的加强。Spring中的AOP代理可以是JDK动态代理，也可以是CGLIB代理，前者基于接口，后者基于子类。\n作用\r简化代码：把方法中固定位置的重复的代码 抽取 出来，让被抽取的方法更专注于自己的核心功能，提高内聚性。\n代码增强：把特定的功能封装到切面类中，看哪里有需要，就往上套，被套用了切面逻辑的方法就 被切面给增强了。\n基于注解的****AOP\r技术说明\r动态代理（InvocationHandler）：JDK原生的实现方式，需要被代理的目标类必须实现接口。因为这个技术要求代理对象和目标对象实现同样的接口（兄弟两个拜把子模式）。\ncglib：通过继承被代理的目标类（认干爹模式）实现代理，所以不需要目标类实现接口。\nAspectJ：本质上是静态代理，将代理逻辑**“织入”**被代理的目标类编译得到的字节码文件，所以最终效果是动态的。weaver就是织入器。Spring只是借用了AspectJ中的注解。\n准备工作\r添加依赖\n\u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-context\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;5.3.1\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!-- spring-aspects会帮我们传递过来aspectjweaver --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-aspects\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;5.3.1\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!-- junit测试 --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;junit\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;junit\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;4.12\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;test\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt;\u0026lt;/dependencies\u0026gt; 准备被代理的目标资源\n接口：\npublic interface Calculator { int add(int i, int j); int sub(int i, int j); int mul(int i, int j); int div(int i, int j);} 实现类：\n@Component public class CalculatorPureImpl implements Calculator { @Override public int add(int i, int j) { int result = i + j; System.out.println(\u0026#34;方法内部 result = \u0026#34; + result); return result; } @Override public int sub(int i, int j) { int result = i - j; System.out.println(\u0026#34;方法内部 result = \u0026#34; + result); return result; } @Override public int mul(int i, int j) { int result = i * j; System.out.println(\u0026#34;方法内部 result = \u0026#34; + result); return result; } @Override public int div(int i, int j) { int result = i / j; System.out.println(\u0026#34;方法内部 result = \u0026#34; + result); return result; } } 创建切面类并配置\r// @Aspect表示这个类是一个切面类@Aspect// @Component注解保证这个切面类能够放入IOC容器@Componentpublic class LogAspect { @Before(\u0026#34;execution(public int com.atguigu.aop.annotation.CalculatorImpl.*(..))\u0026#34;) public void beforeMethod(JoinPoint joinPoint) { String methodName = joinPoint.getSignature().getName(); String args = Arrays.toString(joinPoint.getArgs()); System.out.println(\u0026#34;Logger--\u0026gt;前置通知，方法名：\u0026#34; + methodName + \u0026#34;，参数：\u0026#34;+args); } @After(\u0026#34;execution(* com.atguigu.aop.annotation.CalculatorImpl.*(..))\u0026#34;) public void afterMethod(JoinPoint joinPoint) { String methodName = joinPoint.getSignature().getName(); System.out.println(\u0026#34;Logger--\u0026gt;后置通知，方法名：\u0026#34; + methodName); } @AfterReturning(value = \u0026#34;execution(* com.atguigu.aop.annotation.CalculatorImpl.*(..))\u0026#34;, returning = \u0026#34; result\u0026#34;) public void afterReturningMethod(JoinPoint joinPoint, Object result) { String methodName = joinPoint.getSignature().getName(); System.out.println(\u0026#34;Logger--\u0026gt;返回通知，方法名：\u0026#34; + methodName + \u0026#34;，结果：\u0026#34;+result); } @AfterThrowing(value = \u0026#34;execution(* com.atguigu.aop.annotation.CalculatorImpl.*(..))\u0026#34;, throwing = \u0026#34; ex\u0026#34;) public void afterThrowingMethod(JoinPoint joinPoint, Throwable ex) { String methodName = joinPoint.getSignature().getName(); System.out.println(\u0026#34;Logger--\u0026gt;异常通知，方法名：\u0026#34; + methodName + \u0026#34;，异常：\u0026#34; + ex); } @Around(\u0026#34;execution(* com.atguigu.aop.annotation.CalculatorImpl.*(..))\u0026#34;) public Object aroundMethod(ProceedingJoinPoint joinPoint) { String methodName = joinPoint.getSignature().getName(); String args = Arrays.toString(joinPoint.getArgs()); Object result = null; try { System.out.println(\u0026#34;环绕通知--\u0026gt;目标对象方法执行之前\u0026#34;);//目标对象（连接点）方法的执行 result = joinPoint.proceed(); System.out.println(\u0026#34;环绕通知--\u0026gt;目标对象方法返回值之后\u0026#34;); } catch (Throwable throwable) { throwable.printStackTrace(); System.out.println(\u0026#34;环绕通知--\u0026gt;目标对象方法出现异常时\u0026#34;); } finally { System.out.println(\u0026#34;环绕通知--\u0026gt;目标对象方法执行完毕\u0026#34;); } return result; }} 在 Spring 的配置文件中配置：\n\u0026lt;!--基于注解的AOP的实现：1、将目标对象和切面交给IOC容器管理（注解+扫描）2、开启AspectJ的自动代理，为目标对象自动生成代理3、将切面类通过注解@Aspect标识--\u0026gt;\u0026lt;context:component-scan base-package=\u0026#34;com.atguigu.aop.annotation\u0026#34;\u0026gt;\u0026lt;/context:component-scan\u0026gt;\u0026lt;aop:aspectj-autoproxy /\u0026gt; 各种通知\r前置通知：使用@Before注解标识，在被代理的目标方法前执行 返回通知：使用@AfterReturning注解标识，在被代理的目标方法成功结束后执行（寿终正寝） 异常通知：使用@AfterThrowing注解标识，在被代理的目标方法异常结束后执行（死于非命） 后置通知：使用@After注解标识，在被代理的目标方法最终结束后执行（盖棺定论） 环绕通知：使用@Around注解标识，使用try\u0026hellip;catch\u0026hellip;finally结构围绕整个被代理的目标方法，包 括上面四种通知对应的所有位置 切入点表达式语法\r作用\n2. 语法细节\n用*号代替“权限修饰符”和“返回值”部分表示“权限修饰符”和“返回值”不限 在包名的部分，一个“*”号只能代表包的层次结构中的一层，表示这一层是任意的。 例如：*.Hello匹配com.Hello，不匹配com.atguigu.Hello 在包名的部分，使用“*..”表示包名任意、包的层次深度任意 在类名的部分，类名部分整体用*号代替，表示类名任意 在类名的部分，可以使用*号代替类名的一部分 例如：*Service匹配所有名称以Service结尾的类或接口 在方法名部分，可以使用*号表示方法名任意 在方法名部分，可以使用*号代替方法名的一部分 例如：*Operation匹配所有方法名以Operation结尾的方法 在方法参数列表部分，使用(..)表示参数列表任意 在方法参数列表部分，使用(int,..)表示参数列表以一个int类型的参数开头 在方法参数列表部分，基本数据类型和对应的包装类型是不一样的 切入点表达式中使用 int 和实际方法中 Integer 是不匹配的 在方法返回值部分，如果想要明确指定一个返回值类型，那么必须同时写明权限修饰符 例如：execution(public int _.._Service.*(.., int)) 正确 例如：execution(* int _.._Service.*(.., int)) 错误 重用切入点表达式\r声明\n@Pointcut(\u0026#34;execution(* com.atguigu.aop.annotation.*.*(..))\u0026#34;) public void pointCut(){} 在同一个切面中使用\n@Before(\u0026#34;pointCut()\u0026#34;) public void beforeMethod(JoinPoint joinPoint){ String methodName = joinPoint.getSignature().getName(); String args = Arrays.toString(joinPoint.getArgs()); System.out.println(\u0026#34;Logger--\u0026gt;前置通知，方法名：\u0026#34;+methodName+\u0026#34;，参数：\u0026#34;+args);} 在不同切面中使用\n@Before(\u0026#34;com.atguigu.aop.CommonPointCut.pointCut()\u0026#34;) public void beforeMethod(JoinPoint joinPoint){ String methodName = joinPoint.getSignature().getName(); String args = Arrays.toString(joinPoint.getArgs()); System.out.println(\u0026#34;Logger--\u0026gt;前置通知，方法名：\u0026#34;+methodName+\u0026#34;，参数：\u0026#34;+args);} 获取通知的相关信息\r获取连接点信息\n获取连接点信息可以在通知方法的参数位置设置 JoinPoint 类型的形参\n@Before(\u0026#34;execution(public int com.atguigu.aop.annotation.CalculatorImpl.*(..))\u0026#34;)public void beforeMethod(JoinPoint joinPoint){ //获取连接点的签名信息 String methodName = joinPoint.getSignature().getName(); //获取目标方法到的实参信息 String args = Arrays.toString(joinPoint.getArgs()); System.out.println(\u0026#34;Logger--\u0026gt;前置通知，方法名：\u0026#34;+methodName+\u0026#34;，参数：\u0026#34;+args);} 获取目标方法的返回值\n@AfterReturning 中的属性 returning ，用来将通知方法的某个形参，接收目标方法的返回值\n@AfterReturning(value = \u0026#34;execution(* com.atguigu.aop.annotation.CalculatorImpl.*(..))\u0026#34;, returning = \u0026#34;result\u0026#34;) public void afterReturningMethod(JoinPoint joinPoint, Object result){ String methodName = joinPoint.getSignature().getName(); System.out.println(\u0026#34;Logger--\u0026gt;返回通知，方法名：\u0026#34;+methodName+\u0026#34;，结果：\u0026#34;+result);} 获取目标方法的异****常\n@AfterThrowing 中的属性 throwing ，用来将通知方法的某个形参，接收目标方法的异常\n@AfterThrowing(value = \u0026#34;execution(* com.atguigu.aop.annotation.CalculatorImpl.*(..))\u0026#34;, throwing = \u0026#34;ex\u0026#34;) public void afterThrowingMethod(JoinPoint joinPoint, Throwable ex){ String methodName = joinPoint.getSignature().getName(); System.out.println(\u0026#34;Logger--\u0026gt;异常通知，方法名：\u0026#34;+methodName+\u0026#34;，异常：\u0026#34;+ex);} 环绕通知\r@Around(\u0026#34;execution(* com.atguigu.aop.annotation.CalculatorImpl.*(..))\u0026#34;)public Object aroundMethod(ProceedingJoinPoint joinPoint){ String methodName = joinPoint.getSignature().getName(); String args = Arrays.toString(joinPoint.getArgs()); Object result = null; try { System.out.println(\u0026#34;环绕通知--\u0026gt;目标对象方法执行之前\u0026#34;); //目标方法的执行，目标方法的返回值一定要返回给外界调用者 result = joinPoint.proceed(); System.out.println(\u0026#34;环绕通知--\u0026gt;目标对象方法返回值之后\u0026#34;); } catch (Throwable throwable) { throwable.printStackTrace(); System.out.println(\u0026#34;环绕通知--\u0026gt;目标对象方法出现异常时\u0026#34;); } finally { System.out.println(\u0026#34;环绕通知--\u0026gt;目标对象方法执行完毕\u0026#34;); } return result;} 切面的优先级\r相同目标方法上同时存在多个切面时，切面的优先级控制切面的 内外嵌套 顺序。\n优先级高的切面：外面 优先级低的切面：里面 使用@Order注解可以控制切面的优先级：\n@Order(较小的数)：优先级高\n@Order(较大的数)：优先级低\n声明式事务\rJdbcTemplate\r简介\rSpring 框架对 JDBC 进行封装，使用 JdbcTemplate 方便实现对数据库操作 （暂时使用）\n准备工作\r加入依赖\n\u0026lt;dependencies\u0026gt; \u0026lt;!-- 基于Maven依赖传递性，导入spring-context依赖即可导入当前所需所有jar包 --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-context\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;5.3.1\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!-- Spring 持久化层支持jar包 --\u0026gt; \u0026lt;!-- Spring 在执行持久化层操作、与持久化层技术进行整合过程中，需要使用orm、jdbc、tx三个 jar包 --\u0026gt; \u0026lt;!-- 导入 orm 包就可以通过 Maven 的依赖传递性把其他两个也导入 --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-orm\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;5.3.1\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!-- Spring 测试相关 --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-test\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;5.3.1\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!-- junit测试 --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;junit\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;junit\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;4.12\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;test\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!-- MySQL驱动 --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;mysql\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mysql-connector-java\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;8.0.16\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!-- 数据源 --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.alibaba\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;druid\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0.31\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt;\u0026lt;/dependencies\u0026gt; 创建 jdbc.properties\njdbc.user=rootjdbc.password=atguigujdbc.url=jdbc:mysql://localhost:3306/ssmjdbc.driver=com.mysql.cj.jdbc.Driver 配置 Spring 的配置文件\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt;\u0026lt;beans xmlns=\u0026#34;http://www.springframework.org/schema/beans\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd\u0026#34;\u0026gt; \u0026lt;!-- 导入外部属性文件 --\u0026gt; \u0026lt;context:property-placeholder location=\u0026#34;classpath:jdbc.properties\u0026#34; /\u0026gt; \u0026lt;!-- 配置数据源 --\u0026gt; \u0026lt;bean id=\u0026#34;druidDataSource\u0026#34; class=\u0026#34;com.alibaba.druid.pool.DruidDataSource\u0026#34;\u0026gt; \u0026lt;property name=\u0026#34;url\u0026#34; value=\u0026#34;${atguigu.url}\u0026#34;/\u0026gt; \u0026lt;property name=\u0026#34;driverClassName\u0026#34; value=\u0026#34;${atguigu.driver}\u0026#34;/\u0026gt; \u0026lt;property name=\u0026#34;username\u0026#34; value=\u0026#34;${atguigu.username}\u0026#34;/\u0026gt; \u0026lt;property name=\u0026#34;password\u0026#34; value=\u0026#34;${atguigu.password}\u0026#34;/\u0026gt; \u0026lt;/bean\u0026gt; \u0026lt;!-- 配置 JdbcTemplate --\u0026gt; \u0026lt;bean id=\u0026#34;jdbcTemplate\u0026#34; class=\u0026#34;org.springframework.jdbc.core.JdbcTemplate\u0026#34;\u0026gt; \u0026lt;!-- 装配数据源 --\u0026gt; \u0026lt;property name=\u0026#34;dataSource\u0026#34; ref=\u0026#34;druidDataSource\u0026#34;/\u0026gt; \u0026lt;/bean\u0026gt;\u0026lt;/beans\u0026gt; 测试\r在测试类装配 JdbcTemplate\n@RunWith(SpringJUnit4ClassRunner.class)@ContextConfiguration(\u0026#34;classpath:spring-jdbc.xml\u0026#34;)public class JDBCTemplateTest { @Autowired private JdbcTemplate jdbcTemplate;} 测试增删改功能\n@Test//测试增删改功能public void testUpdate(){ String sql = \u0026#34;insert into t_emp values(null,?,?,?)\u0026#34;; int result = jdbcTemplate.update(sql, \u0026#34;张三\u0026#34;, 23, \u0026#34;男\u0026#34;); System.out.println(result);} 声明式事务概念\r编程式事务\r事务功能的相关操作全部通过自己编写代码来实现：\nConnection conn = ...;try { // 开启事务：关闭事务的自动提交 conn.setAutoCommit(false); // 核心操作 // 提交事务 conn.commit();}catch(Exception e){// 回滚事务 conn.rollBack();}finally{ // 释放数据库连接 conn.close();} 编程式的实现方式存在缺陷：\n细节没有被屏蔽：具体操作过程中，所有细节都需要程序员自己来完成，比较繁琐。 代码复用性不高：如果没有有效抽取出来，每次实现功能都需要自己编写代码，代码就没有得到复用。 声明式事务\r既然事务控制的代码有规律可循，代码的结构基本是确定的，所以框架就可以将固定模式的代码抽取出来，进行相关的封装。\n封装起来后，我们只需要在配置文件中进行简单的配置即可完成操作。\n好处1：提高开发效率 好处2：消除了冗余的代码 好处3：框架会综合考虑相关领域中在实际开发环境下有可能遇到的各种问题，进行了健壮性、性 能等各个方面的优化 所以，我们可以总结下面两个概念：\n编程式：自己写代码实现功能 声明式：通过配置让框架实现功能 基于注解的声明式事务\r准备工作\r加入依赖\n\u0026lt;dependencies\u0026gt; \u0026lt;!-- 基于Maven依赖传递性，导入spring-context依赖即可导入当前所需所有jar包 --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-context\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;5.3.1\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!-- Spring 持久化层支持jar包 --\u0026gt; \u0026lt;!-- Spring 在执行持久化层操作、与持久化层技术进行整合过程中，需要使用orm、jdbc、tx三个 jar包 --\u0026gt; \u0026lt;!-- 导入 orm 包就可以通过 Maven 的依赖传递性把其他两个也导入 --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-orm\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;5.3.1\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!-- Spring 测试相关 --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-test\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;5.3.1\u0026lt;/version\u0026gt;\u0026lt;/dependency\u0026gt; \u0026lt;!-- junit测试 --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;junit\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;junit\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;4.12\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;test\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!-- MySQL驱动 --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;mysql\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mysql-connector-java\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;8.0.16\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!-- 数据源 --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.alibaba\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;druid\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0.31\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; 创建 jdbc.properties\njdbc.user=rootjdbc.password=123456jdbc.url=jdbc:mysql://localhost:3306/ssm?serverTimezone=UTCjdbc.driver=com.mysql.cj.jdbc.Driver 配置 Spring 的配置文件\n\u0026lt;!--扫描组件--\u0026gt;\u0026lt;context:component-scan base-package=\u0026#34;com.atguigu.spring.tx.annotation\u0026#34;\u0026gt;\u0026lt;/context:component-scan\u0026gt;\u0026lt;!-- 导入外部属性文件 --\u0026gt;\u0026lt;context:property-placeholder location=\u0026#34;classpath:jdbc.properties\u0026#34; /\u0026gt;\u0026lt;!-- 配置数据源 --\u0026gt;\u0026lt;bean id=\u0026#34;druidDataSource\u0026#34; class=\u0026#34;com.alibaba.druid.pool.DruidDataSource\u0026#34;\u0026gt;\u0026lt;property name=\u0026#34;url\u0026#34; value=\u0026#34;${jdbc.url}\u0026#34;/\u0026gt;\u0026lt;property name=\u0026#34;driverClassName\u0026#34; value=\u0026#34;${jdbc.driver}\u0026#34;/\u0026gt;\u0026lt;property name=\u0026#34;username\u0026#34; value=\u0026#34;${jdbc.username}\u0026#34;/\u0026gt;\u0026lt;property name=\u0026#34;password\u0026#34; value=\u0026#34;${jdbc.password}\u0026#34;/\u0026gt;\u0026lt;/bean\u0026gt;\u0026lt;!-- 配置 JdbcTemplate --\u0026gt;\u0026lt;bean id=\u0026#34;jdbcTemplate\u0026#34; class=\u0026#34;org.springframework.jdbc.core.JdbcTemplate\u0026#34;\u0026gt;\u0026lt;!-- 装配数据源 --\u0026gt;\u0026lt;property name=\u0026#34;dataSource\u0026#34; ref=\u0026#34;druidDataSource\u0026#34;/\u0026gt;\u0026lt;/bean\u0026gt; 创建表\nCREATE TABLE `t_book` ( `book_id` int(11) NOT NULL AUTO_INCREMENT COMMENT \u0026#39;主键\u0026#39;, `book_name` varchar(20) DEFAULT NULL COMMENT \u0026#39;图书名称\u0026#39;, `price` int(11) DEFAULT NULL COMMENT \u0026#39;价格\u0026#39;, `stock` int(10) unsigned DEFAULT NULL COMMENT \u0026#39;库存（无符号）\u0026#39;, PRIMARY KEY (`book_id`)) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8;insert into `t_book`(`book_id`,`book_name`,`price`,`stock`) values (1,\u0026#39;斗破苍穹\u0026#39;,80,100),(2,\u0026#39;斗罗大陆\u0026#39;,50,100);CREATE TABLE `t_user` ( `user_id` int(11) NOT NULL AUTO_INCREMENT COMMENT \u0026#39;主键\u0026#39;, `username` varchar(20) DEFAULT NULL COMMENT \u0026#39;用户名\u0026#39;, `balance` int(10) unsigned DEFAULT NULL COMMENT \u0026#39;余额（无符号）\u0026#39;, PRIMARY KEY (`user_id`)) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8;insert into `t_user`(`user_id`,`username`,`balance`) values (1,\u0026#39;admin\u0026#39;,50); 创建组件\n创建 BookController ：\n@Controllerpublic class BookController { @Autowired private BookService bookService;public void buyBook(Integer bookId, Integer userId){ bookService.buyBook(bookId, userId); }} 创建接口 BookService ：\npublic interface BookService { void buyBook(Integer bookId, Integer userId);} 创建实现类 BookServiceImpl ：\n@Servicepublic class BookServiceImpl implements BookService { @Autowired private BookDao bookDao; @Override public void buyBook(Integer bookId, Integer userId) { //查询图书的价格 Integer price = bookDao.getPriceByBookId(bookId); //更新图书的库存 bookDao.updateStock(bookId); //更新用户的余额 bookDao.updateBalance(userId, price); }} 创建接口 BookDao ：\npublic interface BookDao { Integer getPriceByBookId(Integer bookId); void updateStock(Integer bookId); void updateBalance(Integer userId, Integer price);} 创建实现类BookDaoImpl：\n@Repositorypublic class BookDaoImpl implements BookDao { @Autowired private JdbcTemplate jdbcTemplate;@Overridepublic Integer getPriceByBookId(Integer bookId) { String sql = \u0026#34;select price from t_book where book_id = ?\u0026#34;; return jdbcTemplate.queryForObject(sql, Integer.class, bookId);}@Overridepublic void updateStock(Integer bookId) { String sql = \u0026#34;update t_book set stock = stock - 1 where book_id = ?\u0026#34;; jdbcTemplate.update(sql, bookId);}@Overridepublic void updateBalance(Integer userId, Integer price) { String sql = \u0026#34;update t_user set balance = balance - ? where user_id =?\u0026#34;; jdbcTemplate.update(sql, price, userId); }} 加入事务\r添加事务配置\n在 Spring 的配置文件中添加配置：\n\u0026lt;bean id=\u0026#34;transactionManager\u0026#34;class=\u0026#34;org.springframework.jdbc.datasource.DataSourceTransactionManager\u0026#34;\u0026gt;\u0026lt;property name=\u0026#34;dataSource\u0026#34; ref=\u0026#34;dataSource\u0026#34;\u0026gt;\u0026lt;/property\u0026gt;\u0026lt;/bean\u0026gt; \u0026lt;!-- 开启事务的注解驱动 通过注解@Transactional所标识的方法或标识的类中所有的方法，都会被事务管理器管理事务 --\u0026gt;\u0026lt;!-- transaction-manager属性的默认值是transactionManager，如果事务管理器bean的id正好就是这个默认值，则可以省略这个属性 --\u0026gt;\u0026lt;tx:annotation-driven transaction-manager=\u0026#34;transactionManager\u0026#34; /\u0026gt; 注意：导入的名称空间需要 tx 结尾的那个。 添加事务注解\n因为 service 层表示业务逻辑层，一个方法表示一个完成的功能，因此处理事务一般在 service 层处理\n在 BookServiceImpl 的 buybook() 添加注解 @Transactional\n观察结果\n由于使用了 Spring 的声明式事务，更新库存和更新余额都没有执行\n@Transactional****注解标识的位置\r@Transactional 标识在方法上，咋只会影响该方法\n@Transactional 标识的类上，咋会影响类中所有的方法\n事务属性：只读\r介绍\n对一个查询操作来说，如果我们把它设置成只读，就能够明确告诉数据库，这个操作不涉及写操作。这 样数据库就能够针对查询操作来进行优化。\n使用方式\n@Transactional(readOnly = true)public void buyBook(Integer bookId, Integer userId) { //查询图书的价格 Integer price = bookDao.getPriceByBookId(bookId); //更新图书的库存 bookDao.updateStock(bookId); //更新用户的余额 bookDao.updateBalance(userId, price); //System.out.println(1/0);} 注意\n对增删改操作设置只读会抛出下面异常：\nCaused by: java.sql.SQLException: Connection is read-only. Queries leading to data modification are not allowed\n事务属性：超时\r介绍\n事务在执行过程中，有可能因为遇到某些问题，导致程序卡住，从而长时间占用数据库资源。而长时间占用资源，大概率是因为程序运行出现了问题（可能是 Java 程序或 MySQL 数据库或网络连接等等）。\n此时这个很可能出问题的程序应该被回滚，撤销它已做的操作，事务结束，把资源让出来，让其他正常 程序可以执行。\n概括来说就是一句话：超时回滚，释放资源。\n使用方式\n@Transactional(timeout = 3)public void buyBook(Integer bookId, Integer userId) {try { TimeUnit.SECONDS.sleep(5);} catch (InterruptedException e) { e.printStackTrace();} //查询图书的价格 Integer price = bookDao.getPriceByBookId(bookId); //更新图书的库存 bookDao.updateStock(bookId); //更新用户的余额 bookDao.updateBalance(userId, price); //System.out.println(1/0);} 观察结果\n执行过程中抛出异常：\norg.springframework.transaction. TransactionTimedOutException : Transaction timed out:\ndeadline was Fri Jun 04 16:25:39 CST 2022\n事务属性：回滚策略\r介绍\n声明式事务默认只针对运行时异常回滚，编译时异常不回滚。\n可以通过@Transactional中相关属性设置回滚策略\nrollbackFor属性：需要设置一个Class类型的对象\nrollbackForClassName属性：需要设置一个字符串类型的全类名\nnoRollbackFor属性：需要设置一个Class类型的对象\nrollbackFor属性：需要设置一个字符串类型的全类名\n使用方式\n@Transactional(noRollbackFor = ArithmeticException.class)//@Transactional(noRollbackForClassName = \u0026#34;java.lang.ArithmeticException\u0026#34;)public void buyBook(Integer bookId, Integer userId) { //查询图书的价格 Integer price = bookDao.getPriceByBookId(bookId); //更新图书的库存 bookDao.updateStock(bookId); //更新用户的余额 bookDao.updateBalance(userId, price); System.out.println(1/0);} 观察结果\n虽然购买图书功能中出现了数学运算异常（ ArithmeticException ），但是我们设置的回滚策略是，当出现 ArithmeticException 不发生回滚，因此购买图书的操作正常执行\n事务属性：事务隔离级别\r介绍\n数据库系统必须具有隔离并发运行各个事务的能力，使它们不会相互影响，避免各种并发问题。一个事\n务与其他事务隔离的程度称为隔离级别。 SQL 标准中规定了多种事务隔离级别，不同隔离级别对应不同\n的干扰程度，隔离级别越高，数据一致性就越好，但并发性越弱。\n隔离级别一共有四种：\n读未提交： READ UNCOMMITTED\n允许 Transaction01 读取 Transaction02 未提交的修改。\n读已提交： READ COMMITTED 、\n要求 Transaction01 只能读取 Transaction02 已提交的修改。\n可重复读： REPEATABLE READ\n确保 Transaction01 可以多次从一个字段中读取到相同的值，即 Transaction01 执行期间禁止其它事务对这个字段进行更新。\n串行化： SERIALIZABLE\n确保 Transaction01 可以多次从一个表中读取到相同的行，在Transaction01执行期间，禁止其它事务对这个表进行添加、更新、删除操作。可以避免任何并发问题，但性能十分低下。\n各个隔离级别解决并发问题的能力见下表：\n使用方式\n@Transactional(isolation = Isolation.DEFAULT)//使用数据库默认的隔离级别@Transactional(isolation = Isolation.READ_UNCOMMITTED)//读未提交@Transactional(isolation = Isolation.READ_COMMITTED)//读已提交@Transactional(isolation = Isolation.REPEATABLE_READ)//可重复读@Transactional(isolation = Isolation.SERIALIZABLE)//串行化 事务属性：事务传播行为\r介绍\n当事务方法被另一个事务方法调用时，必须指定事务应该如何传播。例如：方法可能继续在现有事务中\n运行，也可能开启一个新事务，并在自己的事务中运行。\n测试\n创建接口CheckoutService：\npublic interface CheckoutService { void checkout(Integer[] bookIds, Integer userId);} 创建实现类 CheckoutServiceImpl ：\n@Servicepublic class CheckoutServiceImpl implements CheckoutService { @Autowired private BookService bookService; @Override @Transactional //一次购买多本图书 public void checkout(Integer[] bookIds, Integer userId) { for (Integer bookId : bookIds) { bookService.buyBook(bookId, userId); } }} 在 BookController 中添加方法：\n@Autowiredprivate CheckoutService checkoutService;public void checkout(Integer[] bookIds, Integer userId){ checkoutService.checkout(bookIds, userId);} 在数据库中将用户的余额修改为 100 元\n观察结果\n可以通过 @Transactional 中的 propagation 属性设置事务传播行为\n修改 BookServiceImpl 中 buyBook() 上，注解 @Transactional 的 propagation 属性\n@Transactional(propagation = Propagation.REQUIRED) ，默认情况，表示如果当前线程上有已经开启的事务可用，那么就在这个事务中运行。经过观察，购买图书的方法 buyBook() 在 checkout() 中被调用， checkout() 上有事务注解，因此在此事务中执行。所购买的两本图书的价格为 80 和 50 ，而用户的余额为 100 ，因此在购买第二本图书时余额不足失败，导致整个 checkout() 回滚，即只要有一本书买不了，就都买不了\n@Transactional(propagation = Propagation.REQUIRES_NEW) ，表示不管当前线程上是否有已经开启的事务，都要开启新事务。同样的场景，每次购买图书都是在 buyBook() 的事务中执行，因此第一本图 书购买成功，事务结束，第二本图书购买失败，只在第二次的 buyBook() 中回滚，购买第一本图书不受 影响，即能买几本就买几本\n肝了一夜 给个收藏鼓励鼓励！！加油！！2023.2.8 6:39:25\r本文转自 https://blog.csdn.net/m0_60824353/article/details/126626053?ops_request_misc=elastic_search_misc\u0026request_id=f447745b1cf053f40561f5c683e5e49a\u0026biz_id=0\u0026utm_medium=distribute.pc_search_result.none-task-blog-2~all~top_positive~default-1-126626053-null-null.142^v102^pc_search_result_base9\u0026utm_term=ssm%E7%AC%94%E8%AE%B0\u0026spm=1018.2226.3001.4187，如有侵权，请联系删除。\n","date":"2026-04-11T00:00:00Z","permalink":"/p/ssm-%E6%A1%86%E6%9E%B6%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0/","title":"SSM 框架学习笔记"},{"content":"Swagger 接口文档工具\r\u0026lt;!--swagger--\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.github.xiaoymin\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;knife4j-openapi2-spring-boot-starter\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;4.1.0\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; knife4j: enable: true openapi: title: 用户管理接口文档 description: \u0026#34;用户管理接口文档\u0026#34; email: zhanghuyi@itcast.cn concat: 虎哥 url: https://www.itcast.cn version: v1.0.0 group: default: group-name: default api-rule: package api-rule-resources: - com.itheima.mp.controller ","date":"2026-04-11T00:00:00Z","permalink":"/p/swagger-%E6%8E%A5%E5%8F%A3%E6%96%87%E6%A1%A3%E5%B7%A5%E5%85%B7/","title":"Swagger 接口文档工具"},{"content":"Tlias 项目 SQL 事务\rspring 事务管理通过 aop 实现 删除部门\r删除部门时 应解散部门下的员工 在服务层对数据尽心多次增删改的方法前加注解 logging: level: org.springframework.jdbc.support.JdbcTransactionManager: debug @Transactional @Override public void delete(Integer id) { deptMapper.deleteById(id); int i = 1/0; empMapper.deleteByDept(id); } Transaction\r回滚什么异常 事务传播行为\r但是 事务的默认值是 required ,insert的事务和delete的事务是一个事务, delete事务回滚insert方法内的操作也会回滚, 因此日志不会插入成功. 解决方法是 把insert改成新的事物, propagation = REQUIRES_NEW, 调用insert时 , 会挂起delete事务, 执行insert事务, 完毕后执行delete事务,delete回滚时insert已经提交了, insert不会回滚. ","date":"2026-04-11T00:00:00Z","permalink":"/p/tlias-%E9%A1%B9%E7%9B%AE-sql-%E4%BA%8B%E5%8A%A1/","title":"Tlias 项目 SQL 事务"},{"content":"Tlias 项目登录功能\r登录校验\r;要对所有接口做校验 /emps/.. /depts/.. 每个方法内实现太冗余, 因此一般使用 统一拦截. 会话技术\r访问web 资源, 会话建立, 一方断开连接, 会话结束, 一次会话可以有多个请求 可以理解为 一个会话就是一个浏览器, 关闭浏览器会关闭会话. 会话跟踪: 识别请求是否来自同一个会话. 同一个会话可以共享数据. cookie session 令牌技术 cookie\r存储在客户端(浏览器); 跨域: 前端一个服务器, 后端一个服务器, 跨域cookie不能传递. session\r存储在服务端 令牌\r缺点: 自己实现\n令牌伪造? 有签名防止伪造\njwt\rjson web token @Test public void testGenJwt(){ Map\u0026lt;String, Object\u0026gt; claims = new HashMap\u0026lt;\u0026gt;(); claims.put(\u0026#34;id\u0026#34;,1); claims.put(\u0026#34;name\u0026#34;,\u0026#34;Tom\u0026#34;); String jwt = Jwts.builder() .signWith(Jwts.SIG.HS256.key().build()) .claims(claims) .expiration(new Date(System.currentTimeMillis() + 3600 * 1000)) .compact(); log.info(jwt); } // 生成 @Test public void testGenJwt(){ Map\u0026lt;String, Object\u0026gt; claims = new HashMap\u0026lt;\u0026gt;(); claims.put(\u0026#34;id\u0026#34;,1); claims.put(\u0026#34;name\u0026#34;,\u0026#34;Tom\u0026#34;); // hs256 秘钥 32 字节或更长 String secretString = \u0026#34;itheimaaitheimaaitheimaaitheimaa\u0026#34;; SecretKey key = Keys.hmacShaKeyFor(secretString.getBytes()); SecretKey key1 = Jwts.SIG.HS256.key().random(new SecureRandom(secretString.getBytes())).build(); String jwt = Jwts.builder() .signWith(key1) //.signWith(key, Jwts.SIG.HS256) .claims(claims) .expiration(new Date(System.currentTimeMillis() + 3600 * 1000)) .compact(); log.info(jwt); } // 验证 @Test public void testJwtParse(){ String s = \u0026#34;eyJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoiVG9tIiwiaWQiOjEsImV4cCI6MTczNTI5ODIwN30.wSQfN3enbWTxkoPxzC93DMdspWgePCN8ZDyoaLIwzm0\u0026#34;; String s1 = \u0026#34;eyJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoiVG9tIiwiaWQiOjEsImV4cCI6MTczNTMwMzUyN30.EAtq-NKUXQ4ePmEthOCRyKImWYH1tISUOPRW8mI13Y8\u0026#34;; String secretString = \u0026#34;itheimaaitheimaaitheimaaitheimaa\u0026#34;; SecretKey key = Keys.hmacShaKeyFor(secretString.getBytes()); SecretKey key1 = Jwts.SIG.HS256.key().random(new SecureRandom(secretString.getBytes())).build(); Jws\u0026lt;Claims\u0026gt; claimsJws = Jwts.parser() .verifyWith(key1) //.verifyWith(key, Jwts.SIG.HS256) .build() .parseSignedClaims(s1); Map\u0026lt;String, Object\u0026gt; b = claimsJws.getPayload(); log.info(b.toString()); } // https://jwt.io/ java\r@PostMapping(\u0026#34;/login\u0026#34;) public Result login(@RequestBody Emp emp){ log.info(\u0026#34;员工登录{},{}\u0026#34;,emp); Emp empRet = empService.login(emp); if(empRet == null){ return Result.error(\u0026#34;用户名或密码错误\u0026#34;); }else{ Map\u0026lt;String ,Object\u0026gt; map = new HashMap\u0026lt;\u0026gt;(); map.put(\u0026#34;id\u0026#34;,empRet.getId()); map.put(\u0026#34;name\u0026#34;,empRet.getName()); map.put(\u0026#34;username\u0026#34;,empRet.getUsername()); String token = JwtUtils.getToken(map);// jwt 中包含员工登录的信息 return Result.success(token); } } Filter\r默认按类名排序 实现 @WebFilter\rpublic void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { System.out.println(\u0026#34;拦截器\u0026#34;); HttpServletRequest request = (HttpServletRequest) servletRequest; HttpServletResponse response = (HttpServletResponse) servletResponse; // 1. 获取请求url String url = request.getRequestURI(); // 2. 有login 说明是登录请求, 放行 if(url.contains(\u0026#34;login\u0026#34;)){ filterChain.doFilter(servletRequest, servletResponse); return; } // 3. 没有login , 查看token, 没有token 返回错误 String token = request.getHeader(\u0026#34;token\u0026#34;); if(token == null||token.isEmpty()){ // Result.error(\u0026#34;NOT_LOGIN\u0026#34;); // fastJson 转换 String notLogin = JSONObject.toJSONString(Result.error(\u0026#34;NOT_LOGIN\u0026#34;)); response.getWriter().write(notLogin); return; } // 4. 解析token, 错误 返回错误 try { Claims claims = JwtUtils.parseToken(token); } catch (Exception e) { e.printStackTrace(); System.out.println(\u0026#34;解析令牌失败\u0026#34;); String notLogin = JSONObject.toJSONString(Result.error(\u0026#34;NOT_LOGIN\u0026#34;)); response.getWriter().write(notLogin); return; } // 5. 放行 System.out.println(\u0026#34;令牌合法, 放行\u0026#34;); filterChain.doFilter(servletRequest, servletResponse); } interceptor\r路径\r执行流程\r实现 @Configuration\rpublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { System.out.println(\u0026#34;preHandle .........\u0026#34;); // 1. 获取请求url String url = request.getRequestURI(); // 2. 有login 说明是登录请求, 放行 if(url.contains(\u0026#34;login\u0026#34;)){ return true; } // 3. 没有login , 查看token, 没有token 返回错误 String token = request.getHeader(\u0026#34;token\u0026#34;); if(token == null||token.isEmpty()){ // Result.error(\u0026#34;NOT_LOGIN\u0026#34;); // fastJson 转换 String notLogin = JSONObject.toJSONString(Result.error(\u0026#34;NOT_LOGIN\u0026#34;)); response.getWriter().write(notLogin); return false; } // 4. 解析token, 错误 返回错误 try { Claims claims = JwtUtils.parseToken(token); } catch (Exception e) { e.printStackTrace(); System.out.println(\u0026#34;解析令牌失败\u0026#34;); String notLogin = JSONObject.toJSONString(Result.error(\u0026#34;NOT_LOGIN\u0026#34;)); response.getWriter().write(notLogin); return false; } // 5. 放行 System.out.println(\u0026#34;令牌合法, 放行\u0026#34;); return true; } 区别\r","date":"2026-04-11T00:00:00Z","permalink":"/p/tlias-%E9%A1%B9%E7%9B%AE%E7%99%BB%E5%BD%95%E5%8A%9F%E8%83%BD/","title":"Tlias 项目登录功能"},{"content":"Tlias 项目文件上传\r前端\renctype 如果是默认值, 仅仅提交文件名 文本 第三项是可读的, 其他的是二进制,乱码; 接受\r普通表单 , post 形参接受 文件类型 存储\r本地存储\r@Slf4j @RestController public class UploadController { @PostMapping(\u0026#34;/upload\u0026#34;) public Result upload( String name, Integer age, MultipartFile image) throws IOException { log.info(\u0026#34;upload image{},{},{}\u0026#34;, name, age, image); // 获取文件名 String originalFilename = image.getOriginalFilename(); int index = originalFilename.lastIndexOf(\u0026#34;.\u0026#34;); String suffix = originalFilename.substring(index); String newFileName = UUID.randomUUID().toString() + suffix; File file = new File(\u0026#34;D:\\\\BaiduNetdiskDownload\\\\java\\\\\u0026#34;+newFileName); image.transferTo(file); return Result.success(); } } #单个文件大小 spring.servlet.multipart.max-file-size=10MB #单次请求(一个请求可以上传多个文件) spring.servlet.multipart.max-request-size=100MB 示例\rorg.hzl.utils.ImageSaver 类, 标记 Component 使用容器接收\n@Slf4j @Component public class ImageSaver { public String save(MultipartFile image) throws IOException { log.info(\u0026#34;upload image{}\u0026#34;, image); // 获取文件名 String originalFilename = image.getOriginalFilename(); int index = originalFilename.lastIndexOf(\u0026#34;.\u0026#34;); String suffix = originalFilename.substring(index); String newFileName = UUID.randomUUID().toString() + suffix; File file = new File(\u0026#34;D:\\\\BaiduNetdiskDownload\\\\java\\\\\u0026#34; + newFileName); image.transferTo(file); return file.getAbsolutePath(); } } org.hzl.controller.UploadController\n@Slf4j @RestController public class UploadController { @Autowired private ImageSaver imageSaver; @PostMapping(\u0026#34;/upload\u0026#34;) public Result upload( String name, Integer age, MultipartFile image) throws IOException { String url = imageSaver.save(image); return Result.success(url); } } 云存储\r","date":"2026-04-11T00:00:00Z","permalink":"/p/tlias-%E9%A1%B9%E7%9B%AE%E6%96%87%E4%BB%B6%E4%B8%8A%E4%BC%A0/","title":"Tlias 项目文件上传"},{"content":"Tlias 员工与部门管理\r1 接口规范\r2 开发流程\r3 示例\r3.1 查询部门\rslf4j 是日志注解 @Slf4j @RestController public class DeptController { @Autowired private DeptService deptService; @GetMapping(\u0026#34;/depts\u0026#34;) public Result list(){ // System.out.println(\u0026#34;查询全部数据\u0026#34;); log.info(\u0026#34;查询全部数据\u0026#34;); List\u0026lt;Dept\u0026gt; deptList= deptService.list(); return Result.success(deptList); } public interface DeptService { List\u0026lt;Dept\u0026gt; list(); } @Service public class DeptServiceImp implements DeptService { @Autowired private DeptMapper deptMapper; @Override public List\u0026lt;Dept\u0026gt; list() { return deptMapper.list(); } } @Mapper public interface DeptMapper { /** * 查询全部部门 * 简单, 使用注解的方式开发 * @return */ @Select(\u0026#34;select * from dept\u0026#34;) List\u0026lt;Dept\u0026gt; list(); } controller 调用 Service 的默认实现类 DeptServiceImp, 调用 DeptMapper 访问数据库. 3.2 优化 路径提取\r3.3 分页查询 pageHelper\r3.4 条件查询\r//controller @GetMapping(\u0026#34;/emps\u0026#34;) public Result page(Integer page, Integer pageSize, String name , Short gender, @DateTimeFormat(pattern = \u0026#34;yyyy-MM-dd\u0026#34;) LocalDate begin, @DateTimeFormat(pattern = \u0026#34;yyyy-MM-dd\u0026#34;) LocalDate end) { page = page==null?1:page; pageSize = pageSize==null?10:pageSize; log.info(\u0026#34;分页查询参数{},{},{},{},{},{}\u0026#34;,page,pageSize,name,gender,begin,end); PageBean pageBean = empService.page(page,pageSize,name,gender,begin,end); return Result.success(pageBean); } //Service PageBean page(Integer page, Integer pageSize, String name, Short gender, LocalDate begin, LocalDate end); // Service 实现 @Override public PageBean page(Integer page, Integer pageSize, String name, Short gender, LocalDate begin,LocalDate end) { // 原始分页查询 // Long count = empMapper.count(); // List\u0026lt;Emp\u0026gt; page1 = empMapper.page((page-1)*pageSize, pageSize); PageHelper.startPage(page, pageSize); Page\u0026lt;Emp\u0026gt; p = (Page\u0026lt;Emp\u0026gt;) empMapper.list(name, gender, begin, end); return new PageBean(p.getResult(),p.getTotal()); } // empMapper List\u0026lt;Emp\u0026gt; list(String name, Short gender, LocalDate begin, LocalDate end); \u0026lt;select id=\u0026#34;list\u0026#34; resultType=\u0026#34;org.hzl.pojo.Emp\u0026#34;\u0026gt; select * from emp \u0026lt;where\u0026gt; \u0026lt;if test=\u0026#34;name!=null and name!=\u0026#39;\u0026#39; \u0026#34;\u0026gt;name like concat(\u0026#39;%\u0026#39;,#{name},\u0026#39;%\u0026#39;)\u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;gender!=null\u0026#34;\u0026gt;and gender = #{gender}\u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;begin!=null and end!=null\u0026#34;\u0026gt;and entrydate between #{begin} and #{end}\u0026lt;/if\u0026gt; \u0026lt;/where\u0026gt; order by update_time desc\u0026lt;/select\u0026gt; 3.5 删除员工\r@DeleteMapping(\u0026#34;/emps/{ids}\u0026#34;) public Result delete(@PathVariable(\u0026#34;ids\u0026#34;)List\u0026lt;Integer\u0026gt; ids) { log.info(\u0026#34;批量删除{}\u0026#34;,ids); empService.delete(ids); return Result.success(); } // Service void delete(List\u0026lt;Integer\u0026gt; ids); // Service 实现类 @Override public void delete(List\u0026lt;Integer\u0026gt; ids) { empMapper.delete(ids); } // empMapper void delete(List\u0026lt;Integer\u0026gt; ids); \u0026lt;delete id=\u0026#34;delete\u0026#34;\u0026gt; delete from emp where id in \u0026lt;foreach collection=\u0026#34;ids\u0026#34; item = \u0026#34;id\u0026#34; separator=\u0026#34;,\u0026#34; open = \u0026#34;(\u0026#34; close = \u0026#34;)\u0026#34;\u0026gt; #{id} \u0026lt;/foreach\u0026gt; \u0026lt;/delete\u0026gt; 3.6 文件上传\r3.7 修改员工\r注意逗号不要缺 \u0026lt;update id=\u0026#34;update\u0026#34;\u0026gt; update emp \u0026lt;set\u0026gt; \u0026lt;if test=\u0026#34;username!=null and username!=\u0026#39;\u0026#39;\u0026#34;\u0026gt; username = #{username}, \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;name!=null and name!=\u0026#39;\u0026#39;\u0026#34;\u0026gt; name = #{name}, \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;gender!=null\u0026#34;\u0026gt; gender = #{gender}, \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;image!=null\u0026#34;\u0026gt; image = #{image}, \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;job!=null \u0026#34;\u0026gt; job = #{job}, \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;entrydate!=null \u0026#34;\u0026gt; entrydate = #{entrydate}, \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;deptId!=null\u0026#34;\u0026gt; dept_id = #{deptId}, \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;updateTime!=null\u0026#34;\u0026gt; update_time = #{updateTime} \u0026lt;/if\u0026gt; \u0026lt;/set\u0026gt; where id = #{id} \u0026lt;/update\u0026gt; 4 配置文件\r4.1 properties\r4.1.1 @Value\r4.2 yml (推荐)\r数值前要有空格: port: 9000, 9000前要有空格 缩进是空格, 不是tab 同级左侧对其 4.3 @ConfigurationProperties\r自动注入 5 异常处理\rorg.hzl.exception\n@RestControllerAdvice public class GlobalException { @ExceptionHandler(Exception.class) public Result exception(Exception e) { e.printStackTrace(); return Result.error(\u0026#34;操作失败\u0026#34;); } } ","date":"2026-04-11T00:00:00Z","permalink":"/p/tlias-%E5%91%98%E5%B7%A5%E4%B8%8E%E9%83%A8%E9%97%A8%E7%AE%A1%E7%90%86/","title":"Tlias 员工与部门管理"},{"content":"苍穹外卖 Apache POI 导出\rdemo\r//通过poi创建新文件写入文件 public static void write(){ //在内存中创建excel文件 XSSFWorkbook excel = new XSSFWorkbook(); //创建 sheet页 XSSFSheet sheet_info = excel.createSheet(\u0026#34;info\u0026#34;); //在sheet页中创建行对象 XSSFRow row = sheet_info.createRow(0); //在 row 上创建单元格 XSSFCell cell = row.createCell(0); cell.setCellValue(\u0026#34;你好\u0026#34;); row.createCell(3).setCellValue(\u0026#34;城市\u0026#34;); } /** * 读取excel 文件内容 */ public static void read() throws Exception{ FileInputStream fileInputStream = new FileInputStream(new File(\u0026#34;\\\u0026#34;D:\\\\BaiduNetdiskDownload\\\\java\\\\CangQiongWaiMai\\\\资料\\\\资料\\\\day12\\\\itcast.xlsx\\\u0026#34;\u0026#34;)); XSSFWorkbook excel = new XSSFWorkbook(fileInputStream); XSSFSheet sheet1 = excel.getSheet(\u0026#34;Sheet1\u0026#34;); int lastRowNum = sheet1.getLastRowNum(); for(int i =0;i\u0026lt;lastRowNum;i++){ XSSFRow row = sheet1.getRow(i); String cell1 = row.getCell(1).getStringCellValue(); String cell2 = row.getCell(2).getStringCellValue(); log.info(\u0026#34;cell1:{},cell2:{}\u0026#34;,cell1,cell2); } excel.close(); } 项目开发\r使用模板 从 容器中获取 response\n@GetMapping(\u0026#34;/export\u0026#34;) public void export(HttpServletResponse response){ reportService.exportBusinessData(response); } 业务代码\n@Override public void exportBusinessData(HttpServletResponse response) { //获取营业数据 LocalDate nowDate = LocalDate.now().plusDays(-1); LocalDate preDate = nowDate.plusDays(-29); //查询 概览 数据 BusinessDataVO businessData = workspaceService.getBusinessData(LocalDateTime.of(preDate, LocalTime.MIN), LocalDateTime.of(nowDate, LocalTime.MAX)); //写入 excel 文件 InputStream excelStream = this.getClass().getClassLoader().getResourceAsStream(\u0026#34;template/运营数据报表模板.xlsx\u0026#34;); XSSFWorkbook excel =null; ServletOutputStream outputStream =null; try { excel = new XSSFWorkbook(excelStream); XSSFSheet sheet1 = excel.getSheet(\u0026#34;Sheet1\u0026#34;); sheet1.getRow(1).getCell(1).setCellValue(\u0026#34;时间:\u0026#34;+preDate+\u0026#34;-\u0026#34;+nowDate); XSSFRow row3 = sheet1.getRow(3); row3.getCell(2).setCellValue(businessData.getTurnover()); row3.getCell(4).setCellValue(businessData.getOrderCompletionRate()); row3.getCell(6).setCellValue(businessData.getNewUsers()); XSSFRow row4 = sheet1.getRow(4); row4.getCell(2).setCellValue(businessData.getValidOrderCount()); row4.getCell(4).setCellValue(businessData.getUnitPrice()); //填充明细 for (int i = 0; i \u0026lt; 30; i++) { LocalDate date = preDate.plusDays(i); BusinessDataVO businessData1 = workspaceService.getBusinessData( LocalDateTime.of(date, LocalTime.MIN), LocalDateTime.of(date, LocalTime.MAX) ); XSSFRow row = sheet1.getRow(7 + i); row.getCell(1).setCellValue(date.toString()); row.getCell(2).setCellValue(businessData1.getTurnover()); row.getCell(3).setCellValue(businessData1.getValidOrderCount()); row.getCell(4).setCellValue(businessData1.getOrderCompletionRate()); row.getCell(5).setCellValue(businessData1.getUnitPrice()); row.getCell(6).setCellValue(businessData1.getNewUsers()); } // 写到输出流中 outputStream = response.getOutputStream(); excel.write(outputStream); //通过输出流将excel文件输出到浏览器 outputStream.close(); excel.close(); } catch (IOException e) { throw new RuntimeException(e); } } ","date":"2026-04-11T00:00:00Z","permalink":"/p/%E8%8B%8D%E7%A9%B9%E5%A4%96%E5%8D%96-apache-poi-%E5%AF%BC%E5%87%BA/","title":"苍穹外卖 Apache POI 导出"},{"content":"苍穹外卖 HTTP Client 调用\r引入依赖 public class HttpClientTest { /** * 通过httpClient 发送get 请求 */ @Test public void testGet() throws IOException { // 创建httpclient 对象 CloseableHttpClient aDefault = HttpClients.createDefault(); //创建请求对象 HttpGet httpGet = new HttpGet(\u0026#34;http://localhost:8080/user/shop/status\u0026#34;); //发送请求 CloseableHttpResponse response = aDefault.execute(httpGet); System.out.println(\u0026#34;服务端返回状态码:\u0026#34;+response.getStatusLine()); HttpEntity entity = response.getEntity(); String result = EntityUtils.toString(entity); System.out.println(\u0026#34;服务端返回数据为:\u0026#34;+result); //关闭连接 response.close(); aDefault.close(); } @Test public void testPost() throws IOException { //创建连接 CloseableHttpClient client = HttpClients.createDefault(); HttpPost httpPost = new HttpPost(\u0026#34;http://localhost:8080/admin/employee/login\u0026#34;); //构造请求对象 JSONObject jsonObject = new JSONObject(); jsonObject.put(\u0026#34;username\u0026#34;,\u0026#34;admin\u0026#34;); jsonObject.put(\u0026#34;password\u0026#34;,\u0026#34;123456\u0026#34;); String string = jsonObject.toString(); StringEntity stringEntity = new StringEntity(string); // 请求对象设置http格式 stringEntity.setContentEncoding(\u0026#34;utf-8\u0026#34;); stringEntity.setContentType(\u0026#34;application/json\u0026#34;); httpPost.setEntity(stringEntity); // 发送响应 CloseableHttpResponse response = client.execute(httpPost); System.out.println(\u0026#34;响应码\u0026#34;+response.getStatusLine().getStatusCode()); HttpEntity entity = response.getEntity(); String string1 = EntityUtils.toString(entity); System.out.println(\u0026#34;响应体\u0026#34;+string1); // 关闭连接 response.close(); client.close(); } } ","date":"2026-04-11T00:00:00Z","permalink":"/p/%E8%8B%8D%E7%A9%B9%E5%A4%96%E5%8D%96-http-client-%E8%B0%83%E7%94%A8/","title":"苍穹外卖 HTTP Client 调用"},{"content":"苍穹外卖 PageHelper 分页\rpage从1 开始 page填 0 默认查所有的个数. SELECT count(0) FROM employee pageHelper 通过 Threadlocal 实现 ","date":"2026-04-11T00:00:00Z","permalink":"/p/%E8%8B%8D%E7%A9%B9%E5%A4%96%E5%8D%96-pagehelper-%E5%88%86%E9%A1%B5/","title":"苍穹外卖 PageHelper 分页"},{"content":"苍穹外卖 Redis 手动缓存\r手动设置\r@GetMapping(\u0026#34;/list\u0026#34;) public Result\u0026lt;List\u0026lt;DishVO\u0026gt;\u0026gt; list(Long categoryId) { // key:value===\u0026gt; dish_categoryId:string(List\u0026lt;DishVO\u0026gt;) String key = \u0026#34;dish_\u0026#34; + categoryId; // 查询 redis List\u0026lt;DishVO\u0026gt; list = (List\u0026lt;DishVO\u0026gt;) redisTemplate.opsForValue().get(key); // 存在, 直接返回 if(list!=null\u0026amp;\u0026amp;!list.isEmpty()){ log.info(\u0026#34;redis命中:{}\u0026#34;,list); return Result.success(list); } //不存在, 查询数据库, 放到redis,并返回 Dish dish = new Dish(); dish.setCategoryId(categoryId); dish.setStatus(StatusConstant.ENABLE);//查询起售中的菜品 list = dishService.listWithFlavor(dish); // 重新放redis redisTemplate.opsForValue().set(key,list); return Result.success(list); } 数据一致性\r业务分析: admin-\u0026gt;DishController\n新增菜品, 只需清理对应套餐的 cache 即可 批量删除菜品, 清理所有套餐缓存. 修改菜品, 应清理当前菜品所属套餐缓存, 如果修改了菜品的套餐分类, 也要清理修改之后的套餐缓存, 因此直接全部清理. public class DishController { @Autowired private DishService dishService; @Autowired private RedisTemplate redisTemplate; @PostMapping public Result save(@RequestBody DishDTO dishDTO) { log.info(\u0026#34;新增菜品:{}\u0026#34;, dishDTO); dishService.saveWithFlavors(dishDTO); cleanCache(\u0026#34;dish_\u0026#34;+dishDTO.getCategoryId()); return Result.success(); } @GetMapping(\u0026#34;/page\u0026#34;) public Result\u0026lt;PageResult\u0026gt; page(DishPageQueryDTO pageQueryDTO) { log.info(\u0026#34;菜品分页查询{}\u0026#34;, pageQueryDTO); PageResult pageResult = dishService.pageQuery(pageQueryDTO); return Result.success(pageResult); } @DeleteMapping public Result delete(@RequestParam List\u0026lt;Long\u0026gt; ids){ log.info(\u0026#34;批量删除\u0026#34;); dishService.deleteBench(ids); cleanCache(\u0026#34;dish_*\u0026#34;); return Result.success(); } @GetMapping(\u0026#34;/{id}\u0026#34;) public Result\u0026lt;DishVO\u0026gt; getById(@PathVariable Long id){ log.info(\u0026#34;根据id查询菜品{}\u0026#34;,id); DishVO dishVO = dishService.getByIdWithFlavors(id); return Result.success(dishVO); } @PutMapping public Result update(@RequestBody DishDTO dishDTO){ log.info(\u0026#34;修改菜品:{}\u0026#34;,dishDTO); dishService.updateWithFlavors(dishDTO); cleanCache(\u0026#34;dish_*\u0026#34;); return Result.success(); } private void cleanCache(String pattern){ Set keys = redisTemplate.keys(pattern); redisTemplate.delete(keys); } } ","date":"2026-04-11T00:00:00Z","permalink":"/p/%E8%8B%8D%E7%A9%B9%E5%A4%96%E5%8D%96-redis-%E6%89%8B%E5%8A%A8%E7%BC%93%E5%AD%98/","title":"苍穹外卖 Redis 手动缓存"},{"content":"苍穹外卖 Redis 数据缓存\r@Slf4j @Configuration public class RedisConfiguration { @Bean public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory){ // redisConnectionFactory 自动从容器中获取 RedisTemplate redisTemplate = new RedisTemplate(); redisTemplate.setConnectionFactory(redisConnectionFactory); redisTemplate.setKeySerializer(new StringRedisSerializer()); return redisTemplate; } } string\rredisTemplate.opsForValue().set(\u0026#34;city\u0026#34;, \u0026#34;北京\u0026#34;); String city = (String) redisTemplate.opsForValue().get(\u0026#34;city\u0026#34;); System.out.println(city ); redisTemplate.opsForValue().set(\u0026#34;code\u0026#34;,\u0026#34;1234\u0026#34;,3, TimeUnit.MINUTES); redisTemplate.opsForValue().setIfAbsent(\u0026#34;lock\u0026#34;,1); redisTemplate.opsForValue().setIfAbsent(\u0026#34;lock\u0026#34;,2); hash\rredisTemplate.opsForHash().put(\u0026#34;100\u0026#34;,\u0026#34;name\u0026#34;,\u0026#34;tom\u0026#34;); redisTemplate.opsForHash().put(\u0026#34;100\u0026#34;,\u0026#34;age\u0026#34;,\u0026#34;20\u0026#34;); String name = (String) redisTemplate.opsForHash().get(\u0026#34;100\u0026#34;, \u0026#34;name\u0026#34;); Set keys = redisTemplate.opsForHash().keys(\u0026#34;100\u0026#34;); System.out.println(keys); List values = redisTemplate.opsForHash().values(\u0026#34;100\u0026#34;); System.out.println(values); redisTemplate.opsForHash().delete(\u0026#34;100\u0026#34;,\u0026#34;age\u0026#34;); list\rleftPushAll(\u0026#34;mylist\u0026#34;,\u0026#34;a\u0026#34;,\u0026#34;b\u0026#34;,\u0026#34;c\u0026#34;) leftPush(\u0026#34;mylist\u0026#34;,\u0026#34;a\u0026#34;) range(\u0026#34;mylist\u0026#34;,0,-1) rightPop(\u0026#34;mylist\u0026#34;) size(\u0026#34;mylist\u0026#34;) set\radd(\u0026#34;set1\u0026#34;,\u0026#34;a\u0026#34;,\u0026#34;b\u0026#34;,\u0026#34;c\u0026#34;) members(\u0026#34;set1\u0026#34;) -\u0026gt;set size(\u0026#34;set1\u0026#34;) intersect(\u0026#34;set1\u0026#34;,\u0026#34;set2\u0026#34;) union(\u0026#34;set1\u0026#34;,\u0026#34;set2\u0026#34;) remove(\u0026#34;set1\u0026#34;,\u0026#34;a\u0026#34;,\u0026#34;b\u0026#34;) zset\radd(\u0026#34;zset\u0026#34;,\u0026#34;a\u0026#34;,10) add(\u0026#34;zset\u0026#34;,\u0026#34;b\u0026#34;,11) add(\u0026#34;zset\u0026#34;,\u0026#34;c\u0026#34;,12) range(\u0026#34;zset\u0026#34;,0,-1) incrementScore(\u0026#34;zset\u0026#34;,\u0026#34;a\u0026#34;,10) remove(\u0026#34;zset\u0026#34;, \u0026#34;a\u0026#34;, \u0026#34;b\u0026#34;) 通用\rredisTemplate.keys(\u0026#34;*\u0026#34;) redisTemplate.hasKey(\u0026#34;name\u0026#34;) redisTemplate.delete(\u0026#34;name\u0026#34;) ","date":"2026-04-11T00:00:00Z","permalink":"/p/%E8%8B%8D%E7%A9%B9%E5%A4%96%E5%8D%96-redis-%E6%95%B0%E6%8D%AE%E7%BC%93%E5%AD%98/","title":"苍穹外卖 Redis 数据缓存"},{"content":"苍穹外卖 Spring Cache 缓存\r使用方法\rdemo\rcachePut\rcacheable\r先查 redis , 有就返回. 没有就通过反射调用下面的方法, 并将返回结果存到 redis . cacheEvict\r执行完函数后执行清空 cache 操作 项目使用\r@Autowired private SetmealService setmealService; /** * 新增套餐 * @param setmealDTO * @return */ @PostMapping @CacheEvict(cacheNames = \u0026#34;setMealCache\u0026#34;,key = \u0026#34;#setmealDTO.categoryId\u0026#34;) public Result save(@RequestBody SetmealDTO setmealDTO) { // setmealService.saveWithDish(setmealDTO); return Result.success(); } /** * 批量删除套餐 * @param ids * @return */ @DeleteMapping @CacheEvict(cacheNames = \u0026#34;setMealCache\u0026#34;,allEntries = true) public Result delete(@RequestParam List\u0026lt;Long\u0026gt; ids){ // setmealService.deleteBatch(ids); return Result.success(); } @PutMapping @CacheEvict(cacheNames = \u0026#34;setMealCache\u0026#34;,allEntries = true) public Result update(@RequestBody SetmealDTO setmealDTO) { // setmealService.update(setmealDTO); return Result.success(); } ","date":"2026-04-11T00:00:00Z","permalink":"/p/%E8%8B%8D%E7%A9%B9%E5%A4%96%E5%8D%96-spring-cache-%E7%BC%93%E5%AD%98/","title":"苍穹外卖 Spring Cache 缓存"},{"content":"苍穹外卖 Spring Task 定时任务\rcron表达式\r日和周一般是互斥的\n生成器\rhttps://cron.qqe2.com/ demo\rpackage com.sky.task; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; @Component @Slf4j public class MyTask { @Scheduled(cron = \u0026#34;*/5 * * * * *\u0026#34;) public void myTask(){ log.info(\u0026#34;myTask执行中\u0026#34;); } } ","date":"2026-04-11T00:00:00Z","permalink":"/p/%E8%8B%8D%E7%A9%B9%E5%A4%96%E5%8D%96-spring-task-%E5%AE%9A%E6%97%B6%E4%BB%BB%E5%8A%A1/","title":"苍穹外卖 Spring Task 定时任务"},{"content":"苍穹外卖 Swagger 接口文档\r介绍\r示例\r/** * 配置类，注册web层相关组件 */ @Configuration @Slf4j public class WebMvcConfiguration extends WebMvcConfigurationSupport { @Autowired private JwtTokenAdminInterceptor jwtTokenAdminInterceptor; /** * 注册自定义拦截器 * * @param registry */ protected void addInterceptors(InterceptorRegistry registry) { log.info(\u0026#34;开始注册自定义拦截器...\u0026#34;); registry.addInterceptor(jwtTokenAdminInterceptor) .addPathPatterns(\u0026#34;/admin/**\u0026#34;) .excludePathPatterns(\u0026#34;/admin/employee/login\u0026#34;); } /** * 通过knife4j生成接口文档 * @return */ @Bean public Docket docket() { ApiInfo apiInfo = new ApiInfoBuilder() .title(\u0026#34;苍穹外卖项目接口文档\u0026#34;) .version(\u0026#34;2.0\u0026#34;) .description(\u0026#34;苍穹外卖项目接口文档\u0026#34;) .build(); Docket docket = new Docket(DocumentationType.SWAGGER_2) .apiInfo(apiInfo) .select() .apis(RequestHandlerSelectors.basePackage(\u0026#34;com.sky.controller\u0026#34;)) .paths(PathSelectors.any()) .build(); return docket; } /** * 设置静态资源映射 * @param registry */ protected void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(\u0026#34;/doc.html\u0026#34;).addResourceLocations(\u0026#34;classpath:/META-INF/resources/\u0026#34;); registry.addResourceHandler(\u0026#34;/webjars/**\u0026#34;).addResourceLocations(\u0026#34;classpath:/META-INF/resources/webjars/\u0026#34;); } } 注解\r示例\r","date":"2026-04-11T00:00:00Z","permalink":"/p/%E8%8B%8D%E7%A9%B9%E5%A4%96%E5%8D%96-swagger-%E6%8E%A5%E5%8F%A3%E6%96%87%E6%A1%A3/","title":"苍穹外卖 Swagger 接口文档"},{"content":"苍穹外卖 ThreadLocal 用户上下文\r1 使用\r可以把ThreadLocal看成一个全局Map\u0026lt;Thread, Object\u0026gt;：每个线程获取ThreadLocal变量时，总是使用Thread自身作为key, 一个线程上的函数获取到的都是同一个 Object package com.sky.context; public class BaseContext { public static ThreadLocal\u0026lt;Long\u0026gt; threadLocal = new ThreadLocal\u0026lt;\u0026gt;(); public static void setCurrentId(Long id) { threadLocal.set(id); } public static Long getCurrentId() { return threadLocal.get(); } public static void removeCurrentId() { threadLocal.remove(); } } 1.1 及时remove\rtry { threadLocalUser.set(user); ... } finally { threadLocalUser.remove(); } ","date":"2026-04-11T00:00:00Z","permalink":"/p/%E8%8B%8D%E7%A9%B9%E5%A4%96%E5%8D%96-threadlocal-%E7%94%A8%E6%88%B7%E4%B8%8A%E4%B8%8B%E6%96%87/","title":"苍穹外卖 ThreadLocal 用户上下文"},{"content":"苍穹外卖 WebSocket 通信\r介绍\rdemo\r@Component @ServerEndpoint(\u0026#34;/ws/{sid}\u0026#34;) public class WebSocketServer { //存放会话对象 private static Map\u0026lt;String, Session\u0026gt; sessionMap = new HashMap(); /** * 连接建立成功调用的方法 */ @OnOpen public void onOpen(Session session, @PathParam(\u0026#34;sid\u0026#34;) String sid) { System.out.println(\u0026#34;客户端：\u0026#34; + sid + \u0026#34;建立连接\u0026#34;); sessionMap.put(sid, session); } /** * 收到客户端消息后调用的方法 * * @param message 客户端发送过来的消息 */ @OnMessage public void onMessage(String message, @PathParam(\u0026#34;sid\u0026#34;) String sid) { System.out.println(\u0026#34;收到来自客户端：\u0026#34; + sid + \u0026#34;的信息:\u0026#34; + message); } /** * WebSocket配置类，用于注册WebSocket的Bean */@Configuration public class WebSocketConfiguration { @Bean public ServerEndpointExporter serverEndpointExporter() { return new ServerEndpointExporter(); } } 项目-来单提醒\rpublic void paySuccess(String outTradeNo) { //通过webSocket推送消息 Map\u0026lt;String,Object\u0026gt; map = new HashMap\u0026lt;\u0026gt;(); map.put(\u0026#34;type\u0026#34;,1); map.put(\u0026#34;orderId\u0026#34;,ordersDB.getId()); map.put(\u0026#34;content\u0026#34;,\u0026#34;订单号:\u0026#34;+outTradeNo); String jsonString = JSONObject.toJSONString(map); webSocketServer.sendToAllClient(jsonString); 下单且支付成功后弹出消息 ","date":"2026-04-11T00:00:00Z","permalink":"/p/%E8%8B%8D%E7%A9%B9%E5%A4%96%E5%8D%96-websocket-%E9%80%9A%E4%BF%A1/","title":"苍穹外卖 WebSocket 通信"},{"content":"苍穹外卖订单与支付\r订单\r订单表不涉及订单包含的具体内容例如: 米饭, 套餐等等. 订单明细表包含上述内容 订单属于某一时刻, 记录尽可能多的信息. 比如如果不存地址字段, 只用 address_book_id 记录, 如果用户后续删除该地址, 那么系统从订单中找的地址就是错的. 订单明细表是一个个记录, 一个订单可以有多个订单明细记录. 微信支付\r下单接口中: mchid 商户号 openId 唯一标识 用户 和 小程序二者组合的码 notify_url 13:推送支付结果的地址 jsapi下单\r小程序调起微信支付\r小程序调用该方法小程序就会弹出支付窗口. ","date":"2026-04-11T00:00:00Z","permalink":"/p/%E8%8B%8D%E7%A9%B9%E5%A4%96%E5%8D%96%E8%AE%A2%E5%8D%95%E4%B8%8E%E6%94%AF%E4%BB%98/","title":"苍穹外卖订单与支付"},{"content":"苍穹外卖后端项目初始化\r项目结构\r修改 lombok 版本 nginx\r项目使用了 nginx 反代 提高访问速度 负载均衡 负载均衡配置\r","date":"2026-04-11T00:00:00Z","permalink":"/p/%E8%8B%8D%E7%A9%B9%E5%A4%96%E5%8D%96%E5%90%8E%E7%AB%AF%E9%A1%B9%E7%9B%AE%E5%88%9D%E5%A7%8B%E5%8C%96/","title":"苍穹外卖后端项目初始化"},{"content":"苍穹外卖微信登录\r小程序登录流程\r实现\r服务端存储 user 的 openId , appId+code 只会返回一个不变的 openId, 即一个用户在一个 appid 小程序上登录只会返回一个确定的 openId , 这个 openId可以唯一表示该用户. 后续客户端每次请求都会携带token @PostMapping(\u0026#34;/login\u0026#34;) public Result\u0026lt;UserLoginVO\u0026gt; login(@RequestBody UserLoginDTO userLoginDTO){ /** * 获取小程序登录得来的 code * 后端发送 appid secret code 到微信接口==\u0026gt; 获得session_key openId * 后端返回前端 openId,token,id */ log.info(\u0026#34;微信登录：{}\u0026#34;,userLoginDTO.getCode()); // user 中有 openId, User user = userService.wxLogin(userLoginDTO); //创建jwt Map\u0026lt;String,Object\u0026gt; map = new HashedMap(); map.put(JwtClaimsConstant.USER_ID,user.getId()); String jwt = JwtUtil.createJWT(jwtProperties.getUserSecretKey(), jwtProperties.getUserTtl(), map); UserLoginVO vo = UserLoginVO.builder() .id(user.getId()) .openid(user.getOpenid()) .token(jwt) .build(); return Result.success(vo); } public User wxLogin(UserLoginDTO userLoginDTO) { // 获取openid String open_id = getOpenId(userLoginDTO.getCode()); /** * 判断 openid非空,为空表示登录失败 * 不空=\u0026gt;判断是否为新用户(查询用户表) 新-\u0026gt;注册-\u0026gt;返回user * 非新-\u0026gt;返回user */ if (open_id == null) { throw new LoginFailedException(MessageConstant.LOGIN_FAILED); } User user = userMapper.getByOpenId(open_id); if (user == null) { user = User.builder() .openid(open_id) .createTime(LocalDateTime.now()) .build(); userMapper.insert(user); } return user; } /** * 通过 code 获取 openId * @param code * @return */private String getOpenId(String code){ Map\u0026lt;String, String\u0026gt; map = new HashMap(); map.put(\u0026#34;appid\u0026#34;, weChatProperties.getAppid()); map.put(\u0026#34;secret\u0026#34;, weChatProperties.getSecret()); map.put(\u0026#34;js_code\u0026#34;, code); map.put(\u0026#34;grant_type\u0026#34;, \u0026#34;authorization_code\u0026#34;); String json = HttpClientUtil.doGet(WX_LOGIN, map); JSONObject jsonObject = JSON.parseObject(json); String open_id = jsonObject.getString(\u0026#34;openid\u0026#34;); return open_id; } ","date":"2026-04-11T00:00:00Z","permalink":"/p/%E8%8B%8D%E7%A9%B9%E5%A4%96%E5%8D%96%E5%BE%AE%E4%BF%A1%E7%99%BB%E5%BD%95/","title":"苍穹外卖微信登录"},{"content":"苍穹外卖文件上传\r文件上传\rAliOssProperties.java 项目启动会自动加载\n@Component @ConfigurationProperties(prefix = \u0026#34;sky.alioss\u0026#34;) @Data public class AliOssProperties { private String endpoint; private String accessKeyId; private String accessKeySecret; private String bucketName; } 执行上传操作, 这里 AliOssUtil 不会从容器中拿 properties\n@Data @AllArgsConstructor @Slf4j public class AliOssUtil { private String endpoint; private String accessKeyId; private String accessKeySecret; private String bucketName; /** * 文件上传 * * @param bytes * @param objectName * @return */ public String upload(byte[] bytes, String objectName) { // 创建OSSClient实例。 OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret); try { // 创建PutObject请求。 ossClient.putObject(bucketName, objectName, new ByteArrayInputStream(bytes)); } catch (OSSException oe) { System.out.println(\u0026#34;Caught an OSSException, which means your request made it to OSS, \u0026#34; + \u0026#34;but was rejected with an error response for some reason.\u0026#34;); System.out.println(\u0026#34;Error Message:\u0026#34; + oe.getErrorMessage()); System.out.println(\u0026#34;Error Code:\u0026#34; + oe.getErrorCode()); System.out.println(\u0026#34;Request ID:\u0026#34; + oe.getRequestId()); System.out.println(\u0026#34;Host ID:\u0026#34; + oe.getHostId()); } catch (ClientException ce) { System.out.println(\u0026#34;Caught an ClientException, which means the client encountered \u0026#34; + \u0026#34;a serious internal problem while trying to communicate with OSS, \u0026#34; + \u0026#34;such as not being able to access the network.\u0026#34;); System.out.println(\u0026#34;Error Message:\u0026#34; + ce.getMessage()); } finally { if (ossClient != null) { ossClient.shutdown(); } } //文件访问路径规则 https://BucketName.Endpoint/ObjectName StringBuilder stringBuilder = new StringBuilder(\u0026#34;https://\u0026#34;); stringBuilder .append(bucketName) .append(\u0026#34;.\u0026#34;) .append(endpoint) .append(\u0026#34;/\u0026#34;) .append(objectName); log.info(\u0026#34;文件上传到:{}\u0026#34;, stringBuilder.toString()); return stringBuilder.toString(); } } 在自动配置类中添加 Bean 使得 AliyunUtils 初始化并加载到容器中;\n@Configuration public class OssConfiguration { @Bean public AliOssUtil aliOssUtil(AliOssProperties aliOssProperties){ return new AliOssUtil( aliOssProperties.getEndpoint(), aliOssProperties.getAccessKeyId(), aliOssProperties.getAccessKeySecret(), aliOssProperties.getBucketName() ); } } 文件上传 要获得 AliossUtil 对象, 需要进行初始化 @ConfigurationProperties(prefix = \u0026quot;sky.alioss\u0026quot;) 使得从配置中加载配置, @Component 使得配置实体存到容器中, public AliOssUtil aliOssUtil(AliOssProperties aliOssProperties){ 从容器中获取配置实体 @Bean 将 AliossUtil 对象 放到容器中.\n","date":"2026-04-11T00:00:00Z","permalink":"/p/%E8%8B%8D%E7%A9%B9%E5%A4%96%E5%8D%96%E6%96%87%E4%BB%B6%E4%B8%8A%E4%BC%A0/","title":"苍穹外卖文件上传"},{"content":"苍穹外卖项目开发流程\r1 密码加密\rpassword = DigestUtils.md5DigestAsHex(password.getBytes()); if (!password.equals(employee.getPassword())) { //密码错误 throw new PasswordErrorException(MessageConstant.PASSWORD_ERROR); } 登录\rpublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { //判断当前拦截到的是Controller的方法还是其他资源 if (!(handler instanceof HandlerMethod)) { //当前拦截到的不是动态方法，直接放行 return true; } //1、从请求头中获取令牌 String token = request.getHeader(jwtProperties.getAdminTokenName()); //2、校验令牌 try { log.info(\u0026#34;jwt校验:{}\u0026#34;, token); Claims claims = JwtUtil.parseJWT(jwtProperties.getAdminSecretKey(), token); Long empId = Long.valueOf(claims.get(JwtClaimsConstant.EMP_ID).toString()); log.info(\u0026#34;当前员工id：{}\u0026#34;, empId); BaseContext.setCurrentId(empId); //3、通过，放行 return true; } catch (Exception ex) { //4、不通过，响应401状态码 response.setStatus(401); return false; } } 2 员工管理\r2.1 异常\rsql 插入如果同名就会异常 SQLIntegrityConstraintViolationException @ExceptionHandler public Result exceptionHandler(SQLIntegrityConstraintViolationException ex){ String msg = ex.getMessage(); if(msg.contains(\u0026#34;Duplicate\u0026#34;)){ String[] split = msg.split(\u0026#34; \u0026#34;); return Result.error((String) split[2]+ MessageConstant.ALREADY_EXIST); } log.error(\u0026#34;异常信息：{}\u0026#34;, ex.getMessage()); return Result.error(MessageConstant.UNKNOWN_ERROR); } 2.2 新增员工获取操作人\r通过 ThreadLocal 传递 id ThreadLocal 可以看成全局对象, 一个线程只对应一个 key, 所以一个线程上操作的值都是同一个; Springboot 每接收一个socket创建一个线程, 从interceptoer中的 preHandle 到controller 到 service 到 mapper 都是一个线程执行的, 因此可以通过 inteceptor 获取到id 后添加到 ThreadLocal 中; 在Inteceptor中 添加 id\nBaseContext.setCurrentId(empId); 在Service实现类中获取id\nLong currentId = BaseContext.getCurrentId(); 2.3 分页查询\r\u0026lt;select id=\u0026#34;list\u0026#34; resultType=\u0026#34;com.sky.entity.Employee\u0026#34;\u0026gt; select * from employee \u0026lt;where\u0026gt; \u0026lt;if test = \u0026#34;name!=null and name!=\u0026#39;\u0026#39; \u0026#34;\u0026gt; name like concat(\u0026#39;%\u0026#39;,#{name},\u0026#39;%\u0026#39;)\u0026lt;/if\u0026gt; \u0026lt;/where\u0026gt; order by create_time desc\u0026lt;/select\u0026gt; 2.3.1 日期格式\rspringboot 版本低才会出现日期问题 @Configuration @Slf4j public class WebMvcConfiguration extends WebMvcConfigurationSupport { @Override protected void extendMessageConverters(List\u0026lt;HttpMessageConverter\u0026lt;?\u0026gt;\u0026gt; converters) { // super.extendMessageConverters(converters); MappingJackson2HttpMessageConverter msgConverter = new MappingJackson2HttpMessageConverter(); msgConverter.setObjectMapper(new JacksonObjectMapper()); converters.add(0,msgConverter); log.info(\u0026#34;扩展消息转换器\u0026#34;); } /** * 对象映射器:基于jackson将Java对象转为json，或者将json转为Java对象 * 将JSON解析为Java对象的过程称为 [从JSON反序列化Java对象] * 从Java对象生成JSON的过程称为 [序列化Java对象到JSON] */public class JacksonObjectMapper extends ObjectMapper { public static final String DEFAULT_DATE_FORMAT = \u0026#34;yyyy-MM-dd\u0026#34;; //public static final String DEFAULT_DATE_TIME_FORMAT = \u0026#34;yyyy-MM-dd HH:mm:ss\u0026#34;; public static final String DEFAULT_DATE_TIME_FORMAT = \u0026#34;yyyy-MM-dd HH:mm\u0026#34;; public static final String DEFAULT_TIME_FORMAT = \u0026#34;HH:mm:ss\u0026#34;; public JacksonObjectMapper() { super(); //收到未知属性时不报异常 this.configure(FAIL_ON_UNKNOWN_PROPERTIES, false); //反序列化时，属性不存在的兼容处理 this.getDeserializationConfig().withoutFeatures(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); SimpleModule simpleModule = new SimpleModule() .addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_TIME_FORMAT))) .addDeserializer(LocalDate.class, new LocalDateDeserializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_FORMAT))) .addDeserializer(LocalTime.class, new LocalTimeDeserializer(DateTimeFormatter.ofPattern(DEFAULT_TIME_FORMAT))) .addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_TIME_FORMAT))) .addSerializer(LocalDate.class, new LocalDateSerializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_FORMAT))) .addSerializer(LocalTime.class, new LocalTimeSerializer(DateTimeFormatter.ofPattern(DEFAULT_TIME_FORMAT))); //注册功能模块 例如，可以添加自定义序列化器和反序列化器 this.registerModule(simpleModule); } } 修改状态\r涉及修改只使用一个函数 \u0026lt;update id=\u0026#34;update\u0026#34;\u0026gt; update employee \u0026lt;set\u0026gt; \u0026lt;if test=\u0026#34;name!=null and name!=\u0026#39;\u0026#39;\u0026#34;\u0026gt;name = #{name},\u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;username!=null\u0026#34;\u0026gt;username = #{username},\u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;password!=null\u0026#34;\u0026gt;password = #{password},\u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;phone!=null\u0026#34;\u0026gt;phone = #{phone},\u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;sex!=null\u0026#34;\u0026gt;sex = #{sex},\u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;idNumber!=null\u0026#34;\u0026gt;id_Number = #{idNumber},\u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;updateTime!=null\u0026#34;\u0026gt;update_Time = #{updateTime},\u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;updateUser!=null\u0026#34;\u0026gt;update_User = #{updateUser},\u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;status!=null\u0026#34;\u0026gt;status = #{status},\u0026lt;/if\u0026gt; \u0026lt;/set\u0026gt; where id = #{id} \u0026lt;/update\u0026gt; 属性拷贝\rDTO 转实体对象时，可以使用 Spring 的 BeanUtils.copyProperties 减少重复赋值代码。\nBeanUtils.copyProperties(employeeDTO, employee); ","date":"2026-04-11T00:00:00Z","permalink":"/p/%E8%8B%8D%E7%A9%B9%E5%A4%96%E5%8D%96%E9%A1%B9%E7%9B%AE%E5%BC%80%E5%8F%91%E6%B5%81%E7%A8%8B/","title":"苍穹外卖项目开发流程"},{"content":"苍穹外卖项目说明\r1 资料\r瑞吉 https://pan.baidu.com/s/1bxEy2bHiCYQtouifUppsTA\u0026pwd=1234\n苍穹 https://pan.baidu.com/s/1MNDzXyVlr3mtmLgBjcPJVw\u0026pwd=6633\n本地位置 C:\\Users\\20881\\IdeaProjects\\CangQiongWaiMai\\sky-take-out 2 前端\r\u0026#34;D:\\BaiduNetdiskDownload\\java\\CangQiongWaiMai\\nginx-1.20.2\\nginx.exe\u0026#34;\r// 端口号 : 80 3 流程\r4 DTO\r前端提交的数据和实体对象差异较大时 使用dto 5 常见问题和解决方法\r操作逻辑异常\r业务逻辑不允许的操作\n用户删除分类时, 如果分类下有菜品则不能删除, 使用异常来处理 , 对每个异常都要在全局异常处理器中设置对应的 handler吗?\n使用继承思想,抛出子异常, 在全局异常处理器中 用父异常接收并处理;\n父异常\n/** * 业务异常 */ public class BaseException extends RuntimeException { public BaseException() { } public BaseException(String msg) { super(msg); } } 子异常\npublic class DeletionNotAllowedException extends BaseException { public DeletionNotAllowedException(String msg) { super(msg); } } 全局异常处理\n@RestControllerAdvice @Slf4j public class GlobalExceptionHandler { /** * 捕获业务异常 * @param ex * @return */ @ExceptionHandler public Result exceptionHandler(BaseException ex){ log.error(\u0026#34;异常信息：{}\u0026#34;, ex.getMessage()); return Result.error(ex.getMessage()); } 插入异常\r表中unique字段如果重复就会有异常\n```\r2025-01-08 16:47:56.236 ERROR 4276 \u0026mdash; [nio-8080-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is org.springframework.dao.DuplicateKeyException: ``` 用 GlobalExceptionHandler 接受异常, 返回 Result ;\n拦截器token传递\r使用 ThreadLocal 对线程设置变量;\n日期显示问题\r日期显示问题: 公共字段\r这些字段自动填充即可, 如果在每个函数中都手动设置过于冗余且不易维护\n这些aop一般用在mapper层\n使用反射\n编写Log类 实现aop方法 添加log 编写log类\npackage com.sky.annotation; import com.sky.enumeration.OperationType; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) // @interface 表示注解 public @interface AutoFill { //UPDATE INSERT // 注解中会出现 @AutoFill(value = ) 等于的值为 OperationType中指定的 OperationType value(); } 实现aop方法\n@Slf4j @Component @Aspect public class AutoFillAspect { /** * 切入点 */ @Pointcut(\u0026#34;@annotation(com.sky.annotation.AutoFill)\u0026#34;) public void pt(){}; @Before(\u0026#34;@annotation(autoFill)\u0026#34; ) public void AutoFill(JoinPoint joinPoint,AutoFill autoFill) throws NoSuchMethodException, InvocationTargetException, IllegalAccessException { log.info(\u0026#34;开始执行公共字段填充\u0026#34;); //拦截mapper方法 update操作 只用 updateTime updateUser 赋值即可 // 获取方法的参数实体对象 OperationType operationType = autoFill.value(); log.info(\u0026#34;获取到传入value:{}\u0026#34;,operationType); Object[] args = joinPoint.getArgs(); if(args.length==0||args==null){ return; } Object obj = args[0]; //公共属性赋值 LocalDateTime now = LocalDateTime.now(); Long currentId = BaseContext.getCurrentId(); if(operationType==OperationType.INSERT){ //四个公共字段赋值 Method setCreateTime = obj.getClass().getDeclaredMethod(AutoFillConstant.SET_CREATE_TIME, LocalDateTime.class); Method setCreateUser = obj.getClass().getDeclaredMethod(AutoFillConstant.SET_CREATE_USER, Long.class); Method setUpdateTime = obj.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_TIME, LocalDateTime.class); Method setUpdateUser = obj.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_USER, Long.class); setCreateTime.invoke(obj,now); setCreateUser.invoke(obj,currentId); setUpdateTime.invoke(obj,now); setUpdateUser.invoke(obj,currentId); }else{ // 主需要传 update字段 Method setUpdateTime = obj.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_TIME, LocalDateTime.class); Method setUpdateUser = obj.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_USER, Long.class); setUpdateTime.invoke(obj,now); setUpdateUser.invoke(obj,currentId); } } } 两个表操作\r开启事务, 在服务实现类方法上添加 @Transactional 先删除后插入\r更新菜品口味, 口味可能删除了, 因此直接 update业务错误, 要先删后插 多对多的关系表可以先删除后插入 少量数据\r店铺营业状态, 存表不合适 使用 redis 冗余字段\r只存 id 不存其他的字段, 回显时还要进行多表查询, 因此为了避免进行多表查询, 设置冗余字段.\nsql语句返回\r可能是 null 可以额外加个判断 逻辑删除\r例如订单表, 用户删除不是真正的删除 可以在订单表中添加字段表示是否被删除, 在逻辑上增加判断是否已经被删除 规范\r配置\rapplication.yml\nsky: jwt: # 设置jwt签名加密时使用的秘钥 admin-secret-key: itcast # 设置jwt过期时间 #TODO 设置正确的时间 admin-ttl: 720000000 # 设置前端传递过来的令牌名称 admin-token-name: token alioss: access-key-id: ${sky.alioss.access-key-id} access-key-secret: ${sky.alioss.access-key-secret} endpoint: ${sky.alioss.endpoint} bucket-name: ${sky.alioss.bucket-name} application-dev.yml\nsky: datasource: driver-class-name: com.mysql.cj.jdbc.Driver host: localhost port: 3306 database: sky_take_out username: root password: 1234 alioss: access-key-id: access-key-secret: endpoint: oss-cn-beijing.aliyuncs.com bucket-name: hzl-demo 结构\rcommon\rjava代码中的字符串类型全部提取为常量-\u0026gt;constant context 使用 ThreadLocal 一般使用类包装一下 异常通过设置父类和子类和全局异常处理器分别处理 properties 自动加载 result 类 utils 第三方的集成工具: aliyunOss, Jwt pojo\rdto 是接收从 web 传过来的参数的实体 entity 是实际存在的实体 vo 是返回前端的实体 service\rannotation 自定义注解 aspect 自定义aop切片 config WebMvcConfiguration.java 设置拦截器消息转换器 handler 全局异常处理器 interceptor 登录验证拦截器 名字\r/admin/employee 路径-\u0026gt; controller.admin.employeeController /admin/category/page -\u0026gt;controller.admin.categoryController /admin/common/upload -\u0026gt;controller.admin.commonController ","date":"2026-04-11T00:00:00Z","permalink":"/p/%E8%8B%8D%E7%A9%B9%E5%A4%96%E5%8D%96%E9%A1%B9%E7%9B%AE%E8%AF%B4%E6%98%8E/","title":"苍穹外卖项目说明"},{"content":"苍穹外卖消息转换器\r@Configuration @Slf4j public class WebMvcConfiguration extends WebMvcConfigurationSupport { @Override protected void extendMessageConverters(List\u0026lt;HttpMessageConverter\u0026lt;?\u0026gt;\u0026gt; converters) { // super.extendMessageConverters(converters); MappingJackson2HttpMessageConverter msgConverter = new MappingJackson2HttpMessageConverter(); msgConverter.setObjectMapper(new JacksonObjectMapper()); converters.add(0,msgConverter); log.info(\u0026#34;扩展消息转换器\u0026#34;); } package org.hzl.json; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.module.SimpleModule; import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer; import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer; import com.fasterxml.jackson.datatype.jsr310.deser.LocalTimeDeserializer; import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer; import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; import com.fasterxml.jackson.datatype.jsr310.ser.LocalTimeSerializer; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; import java.time.format.DateTimeFormatter; import static com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES; /** * 对象映射器:基于jackson将Java对象转为json，或者将json转为Java对象 * 将JSON解析为Java对象的过程称为 [从JSON反序列化Java对象] * 从Java对象生成JSON的过程称为 [序列化Java对象到JSON] */public class JacksonObjectMapper extends ObjectMapper { public static final String DEFAULT_DATE_FORMAT = \u0026#34;yyyy-MM-dd\u0026#34;; //public static final String DEFAULT_DATE_TIME_FORMAT = \u0026#34;yyyy-MM-dd HH:mm:ss\u0026#34;; public static final String DEFAULT_DATE_TIME_FORMAT = \u0026#34;yyyy-MM-dd HH:mm\u0026#34;; public static final String DEFAULT_TIME_FORMAT = \u0026#34;HH:mm:ss\u0026#34;; public JacksonObjectMapper() { super(); //收到未知属性时不报异常 this.configure(FAIL_ON_UNKNOWN_PROPERTIES, false); //反序列化时，属性不存在的兼容处理 this.getDeserializationConfig().withoutFeatures(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); SimpleModule simpleModule = new SimpleModule() .addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_TIME_FORMAT))) .addDeserializer(LocalDate.class, new LocalDateDeserializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_FORMAT))) .addDeserializer(LocalTime.class, new LocalTimeDeserializer(DateTimeFormatter.ofPattern(DEFAULT_TIME_FORMAT))) .addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_TIME_FORMAT))) .addSerializer(LocalDate.class, new LocalDateSerializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_FORMAT))) .addSerializer(LocalTime.class, new LocalTimeSerializer(DateTimeFormatter.ofPattern(DEFAULT_TIME_FORMAT))); //注册功能模块 例如，可以添加自定义序列化器和反序列化器 this.registerModule(simpleModule); } } 自定义 java 对象到json的转换过程, 设置 LocalDateTime 的序列化和反序列化器; ","date":"2026-04-11T00:00:00Z","permalink":"/p/%E8%8B%8D%E7%A9%B9%E5%A4%96%E5%8D%96%E6%B6%88%E6%81%AF%E8%BD%AC%E6%8D%A2%E5%99%A8/","title":"苍穹外卖消息转换器"},{"content":"单调栈基础与模板\r单调栈基础\r单调递减栈：\n特性 1：插入元素 ele 时，先和栈顶比较。若更小则入栈；否则持续弹栈。 元素弹出时，通常就是它被“结算”的时机。 特性 2：若当前栈为 [a, b, c, d]（单调递减），则 c 左侧第一个比 c 大的是 b，b 左侧第一个比 b 大的是 a。 例子：接雨水\r维护一个栈（栈顶元素对应高度更小）。遍历数组时，如果新元素 num \u0026gt; 栈顶，说明栈顶位置可以计算：\n栈顶出栈并计算贡献。 当前元素继续参与后续比较。 最后将当前下标入栈。 如何判断用递增栈还是递减栈 ^zqyyg3\r如果要找“左侧第一个更大”，通常用递减栈。 如果要找“左侧第一个更小”，通常用递增栈。 原因是：弹出栈顶时需要用到“弹出后新的栈顶”，而这个元素一定在当前遍历位置的左侧。 ","date":"2026-04-11T00:00:00Z","permalink":"/p/%E5%8D%95%E8%B0%83%E6%A0%88%E5%9F%BA%E7%A1%80%E4%B8%8E%E6%A8%A1%E6%9D%BF/","title":"单调栈基础与模板"},{"content":"递归算法题型总结\r{ \u0026#34;a\u0026#34;:\u0026#34;string a\u0026#34;, \u0026#34;b\u0026#34;:\u0026#34;string b\u0026#34;, \u0026#34;c\u0026#34;:get(a), \u0026#34;d\u0026#34;:get(c) } 函数 get_value() 用来获取最终值。 例如：get_value(a) 直接返回 string a；get_value(c) 会先查 a，最终返回 string a；get_value(d) 会先查 c 再查 a，最终也返回 string a。\n这和递归思想非常接近。对于传入参数 params，get_value 的执行逻辑只有两种：\n直接返回。例如传入 a，直接返回 string a。 依赖更深一层的函数调用。例如传入 b，依赖 get_value(a)。 启发\r可以把题目抽象成一个 json 对象：key 是递归函数输入参数，value 是执行逻辑。执行逻辑通常分成两类：直接返回 和 递归调用下一层。对于给定 key，其对应逻辑只取决于该 key（以及必要的全局状态），题目给定后这个映射关系通常就固定了。 如果 value 依赖更深层递归，下一层如何调用要根据函数语义和题意决定。 function get_value(key){ if(判断 key是否为直接返回类型) return return_value; // 下面就是 依赖于更深层次的递归调用 value= get_value(下一层的key: 根据get_value语义确定) // 是否要对返回值做处理 return value+1; //以加一为例, 可能不是简单的返回 下一层调用的返回值, 可能有操作. } 对于没有返回值的递归函数，判断当前层属于哪种情况通常更依赖全局状态；下一层调用方式依然要由函数语义决定。\n做题总结\r递归从哪里开始思考？ 常从第一个或最后一个位置开始，因为这两个位置受到的约束最少。 例如：打家劫舍可按 灵茶山艾府 的思路，从最后一个位置开始推导。 递归可以从本层输入角度和答案角度思考。例如：子集问题，可以判断本层选不选，也可以从答案角度理解为往path中添加元素。但是在求最长公共子序列时，针对输入的选不选显然要比答案角度的共容易转化为动态规划。 ","date":"2026-04-11T00:00:00Z","permalink":"/p/%E9%80%92%E5%BD%92%E7%AE%97%E6%B3%95%E9%A2%98%E5%9E%8B%E6%80%BB%E7%BB%93/","title":"递归算法题型总结"},{"content":"动态规划算法题型总结\r难度分类\r简单\r[3-70爬楼梯](LeetCode 70 爬楼梯.md) [2-62不同路径](LeetCode 62 不同路径.md)\n中等\r[5-198打家劫舍](LeetCode 198 打家劫舍.md) [1-53最大子数组和](LeetCode 53 最大子数组和 - 动态规划解法.md) [LCR095最长子序列](LCR 095 最长公共子序列.md) [8-416分割等和子集](LeetCode 416 分割等和子集.md) [12-1049最后一块石头的重量Ⅱ](LeetCode 1049 最后一块石头的重量 II.md) [10-494目标和](LeetCode 494 目标和.md) [9-474零和一](LeetCode 474 一和零.md) [14-978最长湍流子数组](LeetCode 978 最长湍流子数组.md) 中等偏上 [15-1546和为目标值且不重叠的子数组的最大数目](LeetCode 1546 和为目标值且不重叠的子数组的最大数目.md)\n困难\r滑窗优化dp [16-1871跳跃游戏VII](LeetCode 1871 跳跃游戏 VII.md)\n类型分类\r背包\r[8-416分割等和子集](LeetCode 416 分割等和子集.md) [12-1049最后一块石头的重量Ⅱ](LeetCode 1049 最后一块石头的重量 II.md) [10-494目标和](LeetCode 494 目标和.md) [9-474零和一](LeetCode 474 一和零.md) [11-518零钱兑换](LeetCode 518 零钱兑换 II.md) [6-279完全平方数](LeetCode 279 完全平方数.md)\n零钱兑换: 我们的helper在不能找到可行解时不要返回-1 而是 Integer.MAX 子序列，子集\r[LCR095最长子序列](LCR 095 最长公共子序列.md) [1-53最大子数组和](LeetCode 53 最大子数组和 - 动态规划解法.md)\n分割问题\r[4-139单词拆分](LeetCode 139 单词拆分.md) ：回溯转为dp。\n北航课程\n灵茶山艾府\n动态规划和记忆化递归\r2026年1月29日15:20:39，由递归转动规时，递归参数越少越好，内部可以用循环 https://blog.csdn.net/weixin_43868339/article/details/122838956\n在动态规划I中我们讲到，如果直接采用暴力迭代的方法，时间复杂度会非常高，和暴力穷举区别不大，原因在于迭代过程会产生大量的重复计算，为了避免重复计算，我们前面采取的办法都是从终点向起点倒推，计算所有状态的价值函数，倒推到起点，就能求解出状态转移方程。\n当然，在某些情况下这也不是最优的解法，因为不是所有状态的价值函数都需要计算，也就是说，可能存在“多余计算”的问题。此时，我们不如就按照状态转移方程进行递归，已经计算过的一些价值函数的值储存起来以避免重复计算，这样我们就可以在计算最少的价值函数的情况下求解状态转移方程。这是一种从起点到终点的计算方法，最差情况是需要计算全部的价值函数，但如果我们有一些原则可以排除不可能的路径，那需要计算的价值函数的数量就大大减少。计算全部价值函数的实现方法我们称为狭义的动态规划，后面简称为动态规划。动态规划和记忆化搜索都是动态规划算法的实现方法，各有利弊。\n也就是说：递归（不记忆化）不会计算不需要的值，但可能重复计算；动态规划可能计算不需要的值，但所有的值只会计算一次。\n方法\r有的问题是备忘递归转变而来，例如 [5-198打家劫舍](LeetCode 198 打家劫舍.md) 在想递归时，要想可能的情况，要想路径的可能情况， 而非最后的值。[LCR095最长子序列](LCR 095 最长公共子序列.md) 的回溯算法，想值可能不好想，但是我们假设构造 lcs数组的值就一下子好想了。 有的问题不容易想到递归，例如[1-53最大子数组和](LeetCode 53 最大子数组和 - 动态规划解法.md)，这里的想不到是：使用递归求解的问题并不直接是题目的问题（我们可以求从1开始的最大子数组，进而递归地求从2、 从3、 从4 .。。开始的最大子数组，直到把从{index =1-end} 都遍历一遍，得到从所有位置开始最大子数组，求最大值就是答案。）这显然和看到题目之后能想到的思路相差甚远。 求解问题为 最大子数组，思考这个子数组的可能情况，其起始位置只有n-1种可能，求出从 n-1种位置开始的最大子数组，再求最大。先列举有限种可能情况，每种情况各自找到最大，最后再在所有情况种找最大。 这里的思考过程是倒过来的：有点分治的意思，求最后的解分为求从每个位置开始的最优解，再合：求最大即可。关键在于怎么分：最后球的是数组，数组有基础属性：开始，结束，。。，按开始分，或按结束分，以1开始的有很多：12 123 1234等等；分完之后求每个位置的最优解；再合。 最优子结构指的是，问题的最优解包含子问题的最优解。反过来说就是，我们可以通过子问题的最优解，推导出问题的最优解。如果我们把最优子结构，对应到我们前面定义的动态规划问题模型上，那我们也可以理解为，后面阶段的状态可以通过前面阶段的状态推导出来。 为什么不按长度分呢，按长度也能得到正确答案，但是情况过多且之间的关系更复杂，所以 如何分是关键。 按起始位置分，分完之后的各种情况竟然可以递推。 一个问题如果使用动规，我们要的值可能在最后一个角落，也就是说一直遍历到最后才能找到答案，答案是最后得到的值；也有可能是在遍历的过程中收集结果，最后的答案不一定是最后得到的值。 如果答案是最后的值：那么使用递归求解 递归函数的语义就是问题本身 如果答案不一定是最后的值： 那么递归函数的语义（或者dp数组的语义）就不是直接的问题本身，而是一些可能的情况，最后的答案是从这些可能的情况中按某种规则（题意）选择得到。这里的情况是什么最关键，也就是怎么分 。穷举所有情况-\u0026gt;分类-\u0026gt;加上最优性质-\u0026gt;各个类别之间产生依赖关系（不独立）。 如果没有依赖关系可能就要换个分法了。情况可能有1-2种，这时是递归好想；也可能是n种，递归不好想。 一些常见的分类方法\r针对不是递归的解法的dp，下面列举一些常见的分类方法。\n子数组 一个子数组：列举开始位置或结束位置。[1-53最大子数组和](LeetCode 53 最大子数组和 - 动态规划解法.md) [14-978最长湍流子数组](LeetCode 978 最长湍流子数组.md) 两个数组：列举两个开始位置或结束位置。[13-718最长重复子数组](LeetCode 718 最长重复子数组.md) 0-1背包问题\rhttps://www.bilibili.com/video/BV16Y411v7Y6/?spm_id_from=333.788\u0026vd_source=a9a24992f7f570a16d5a331e8fed9f0d\n对于本层的输入是选还是不选是核心。\n","date":"2026-04-11T00:00:00Z","permalink":"/p/%E5%8A%A8%E6%80%81%E8%A7%84%E5%88%92%E7%AE%97%E6%B3%95%E9%A2%98%E5%9E%8B%E6%80%BB%E7%BB%93/","title":"动态规划算法题型总结"},{"content":"堆数据结构\r堆是一棵完全二叉树，通常用数组存储，满足父节点与子节点之间的优先级关系。常见的操作包括建堆、插入与删除堆顶元素。\n建堆\r堆可以通过逐个插入实现，也可以把一个无序数组当成堆底结构，自底向上依次向下调整以建立最大堆或最小堆。\n插入\r新元素先放到堆尾，再向上调整（父节点索引 (i-1)/2）：如果子节点的值比父节点更大，就交换，直到堆性质恢复。\n删除\r默认只能直接删除堆顶。删除流程是把堆尾元素取代堆顶，再从上往下调整以恢复堆性质。如果想删除堆中间的某个元素，通常的做法是不断 pop() 弹出堆顶，遇到目标元素后再把弹出的值按原顺序重新插入。\n代码（最大堆）\r#include\u0026lt;iostream\u0026gt; #include\u0026lt;vector\u0026gt; using namespace std; class MaxHeap{ public: vector\u0026lt;int\u0026gt; heap; MaxHeap(vector\u0026lt;int\u0026gt;\u0026amp; nums){ heap = nums; for(int i= nums.size()/2-1;i\u0026gt;=0;i--){ tiao_zheng_down(i); } } void push(int num){ heap.push_back(num); tiao_zheng_up(); // 插入新元素时，从堆尾向上调整 } void pop(){ swap(heap[0],heap[heap.size()-1]); heap.pop_back(); tiao_zheng_down(0); } int top(){ return heap[0]; } void tiao_zheng_up(){ int child_index = heap.size()-1; while(child_index\u0026gt;0){ int parent_index = (child_index-1)/2; if(heap[child_index]\u0026gt;heap[parent_index]){ swap(heap[child_index],heap[parent_index]); child_index = parent_index; }else{ break; } } } void tiao_zheng_down(int index){ while(true){ int left_child = 2*index+1; int right_child = left_child+1; int real_index = index; if(left_child\u0026lt;heap.size()\u0026amp;\u0026amp;heap[left_child]\u0026gt;heap[real_index]){ real_index = left_child; } if(right_child\u0026lt;heap.size()\u0026amp;\u0026amp;heap[right_child]\u0026gt;heap[real_index]){ real_index = right_child; } if(real_index!=index){ swap(heap[index],heap[real_index]); index = real_index; }else{ break; } } } }; int main(){ vector\u0026lt;int\u0026gt; nums; MaxHeap maxHeap(nums); maxHeap.push(10); maxHeap.push(20); maxHeap.push(5); maxHeap.push(30); cout\u0026lt;\u0026lt;maxHeap.top()\u0026lt;\u0026lt;endl; maxHeap.pop(); cout\u0026lt;\u0026lt;maxHeap.top()\u0026lt;\u0026lt;endl; } 自定义结构体作为元素\r如果需要按照结构体的某个字段排序，可以通过传入自定义的比较器。例如下面的 CompareTask 返回 true 时说明第一个参数优先级更低，于是 priority_queue 会把优先级大的放在前面，top() 始终返回当前最高优先级任务。\n#include \u0026lt;iostream\u0026gt; #include \u0026lt;queue\u0026gt; #include \u0026lt;vector\u0026gt; struct Task { int priority; std::string description; Task(int p, const std::string\u0026amp; desc) : priority(p), description(desc) {} }; struct CompareTask { bool operator()(const Task\u0026amp; t1, const Task\u0026amp; t2) const { return t1.priority \u0026lt; t2.priority; // 优先级大的在前 } }; int main() { std::priority_queue\u0026lt;Task, std::vector\u0026lt;Task\u0026gt;, CompareTask\u0026gt; pq; pq.push(Task(5, \u0026#34;Write report\u0026#34;)); pq.push(Task(1, \u0026#34;Fix critical bug\u0026#34;)); pq.push(Task(3, \u0026#34;Attend meeting\u0026#34;)); while (!pq.empty()) { Task t = pq.top(); pq.pop(); std::cout \u0026lt;\u0026lt; \u0026#34;Processing task: \u0026#34; \u0026lt;\u0026lt; t.description \u0026lt;\u0026lt; \u0026#34; with priority: \u0026#34; \u0026lt;\u0026lt; t.priority \u0026lt;\u0026lt; std::endl; } return 0; } ","date":"2026-04-11T00:00:00Z","permalink":"/p/%E5%A0%86%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84/","title":"堆数据结构"},{"content":"对称加密、非对称加密与 TLS\r在安全通信场景中，了解对称与非对称加密的职责划分，以及 TLS 和 Diffie-Hellman 的配合，是确保数据保密和完整的基础。\n对称加密\r对称加密使用同一个密钥完成加密与解密，适合处理大量数据的传输场景。常见的实现包括 AES 和 DES。它的主要优势是加解密速度快，网络带宽利用率高。\n但同时也存在关键挑战：密钥必须安全地从发送方传递给接收方，只要密钥泄露，任何人都可以解密通信内容。因此，它常常与密钥协商机制搭配使用，而不是单独应用。\n非对称加密\r非对称加密通过生成一对密钥（公钥和私钥）来实现不同的操作：公钥可以公开，用于加密或验证签名；私钥必须保密，用于解密或签名。通常，一个密钥加密的数据需要用另一把密钥解密。\n典型交互\r以客户端与服务端通信为例：\n服务端将公钥发送给客户端。 客户端用该公钥加密敏感数据并发送。 服务端使用自己的私钥解密数据。 这种模式把私钥的持有权锁定在服务端，降低了密钥泄露带来的风险。\n常见限制与应对措施\r计算量相对较大\rRSA 等非对称算法在计算开销上明显高于 AES，因此通常不用于直接加密大量业务数据。一个常见的改进是混合加密：先用非对称算法协商或保护对称密钥，再用对称算法传输实际数据。当性能要求高时，还可以启用硬件加速或专用安全模块。\n私钥泄露后的风险\r如果私钥泄露，攻击者可以伪装成服务端或解密历史通信。缓解办法包括使用短期会话密钥、定期轮换密钥，以及借助证书管理和密钥托管策略分散单点风险。\nSSL 与 TLS\rSSL 是早期的安全通信协议，TLS 则是其后续版本。现在大多数 HTTPS 连接都是基于 TLS 实现的。\nTLS 的核心目标\r身份认证：确认对方的可信度，通常通过验证服务端证书完成。 密钥协商：让客户端与服务端共同生成会话密钥。 加密传输：后续的业务流量使用对称加密执行。 完整性保护：确保数据在传输过程中不被篡改。 TLS 握手流程\r客户端发送 ClientHello，说明支持的 TLS 版本、加密套件以及随机数。 服务端返回 ServerHello，选定 TLS 版本和加密套件，同时附上服务端随机数。 服务端提供证书，客户端验证证书链、域名与有效期。 双方执行密钥交换，现代 TLS 多使用 ECDHE 等算法生成会话密钥。 协商完成后，双方基于会话密钥加密后续通信。 Diffie-Hellman 密钥交换\rDiffie-Hellman 让双方在无需直接传输密钥的前提下，协商出同一个共享密钥。\n双方约定公开参数 p 和 g。 客户端生成私有随机数 a，计算公开值 A = g^a mod p 并发送给服务端。 服务端生成私有随机数 b，计算公开值 B = g^b mod p 并发送给客户端。 客户端计算 K = B^a mod p。 服务端计算 K = A^b mod p。 由于 (g^b)^a mod p = (g^a)^b mod p，双方可得到相同的共享密钥 K。 这个共享密钥一般不会直接用作业务加密密钥，而是通过密钥派生函数进一步生成实际的密钥材料。\n","date":"2026-04-11T00:00:00Z","permalink":"/p/%E5%AF%B9%E7%A7%B0%E5%8A%A0%E5%AF%86%E9%9D%9E%E5%AF%B9%E7%A7%B0%E5%8A%A0%E5%AF%86%E4%B8%8E-tls/","title":"对称加密、非对称加密与 TLS"},{"content":"二分查找边界写法总结\rhttps://leetcode.cn/problems/search-insert-position/solutions/2023391/er-fen-cha-zhao-zong-shi-xie-bu-dui-yi-g-nq23/?envType=study-plan-v2\u0026envId=top-100-liked\n三种区间写法\r闭区间：询问答案是否在 [left, right]。循环结束时 left \u0026gt; right（即 right + 1 == left），返回 left。 左闭右开：询问答案是否在 [left, right)。结束时 left == right，返回 left（或 right）。 开区间：询问答案是否在 (left, right)。结束时 left + 1 == right，返回 right。 while 条件\r本质是“区间不为空”：\n闭区间：left \u0026lt;= right 左闭右开：left \u0026lt; right 开区间：left + 1 \u0026lt; right 返回 left 还是 right\r程序退出循环时，left/right 的关系是固定的。要返回哪个值，只需分析循环最后一步执行后两个指针的状态。\n也可以通过“循环开始前的不变量”来推导。\n==思考：在闭区间中，left 的含义是什么==\r设 left = 0，0 位置最初是未知的，因此 left 表示的是：[0, ..., left-1] 都小于 target。 对称地，right 表示：[right+1, ..., end] 都大于等于 target。 因此更新 left 时必须保证 left 仍然指向未知区域，即 left = mid + 1（mid 已知）。 在左闭右开写法中，left 从 0 开始，right 从 n 开始（n 是已知边界），因此右侧更新为 right = mid。 if 条件建议只写成 nums[mid] \u0026lt; target 和 nums[mid] \u0026gt;= target 两类，避免混入 \u0026lt;= 导致边界混乱。\nclass Solution { public int searchInsert(int[] nums, int target) { return lowerBound(nums, target); // 选择其中一种写法即可 } // lowerBound 返回最小的满足 nums[i] \u0026gt;= target 的 i // 如果数组为空，或者所有数都 \u0026lt; target，则返回 nums.length // 要求 nums 是非递减的，即 nums[i] \u0026lt;= nums[i + 1] // 闭区间写法 private int lowerBound(int[] nums, int target) { int left = 0; int right = nums.length - 1; // 闭区间 [left, right] while (left \u0026lt;= right) { // 区间不为空 // 循环不变量： // nums[left-1] \u0026lt; target // nums[right+1] \u0026gt;= target int mid = left + (right - left) / 2; if (nums[mid] \u0026lt; target) { left = mid + 1; // 范围缩小到 [mid+1, right] } else { right = mid - 1; // 范围缩小到 [left, mid-1] } } return left; } // 左闭右开区间写法 private int lowerBound2(int[] nums, int target) { int left = 0; int right = nums.length; // 左闭右开区间 [left, right) while (left \u0026lt; right) { // 区间不为空 // 循环不变量： // nums[left-1] \u0026lt; target // nums[right] \u0026gt;= target int mid = left + (right - left) / 2; if (nums[mid] \u0026lt; target) { left = mid + 1; // 范围缩小到 [mid+1, right) } else { right = mid; // 范围缩小到 [left, mid) } } return left; // 或者 right } // 开区间写法 private int lowerBound3(int[] nums, int target) { int left = -1; int right = nums.length; // 开区间 (left, right) while (left + 1 \u0026lt; right) { // 区间不为空 // 循环不变量： // nums[left] \u0026lt; target // nums[right] \u0026gt;= target int mid = left + (right - left) / 2; if (nums[mid] \u0026lt; target) { left = mid; // 范围缩小到 (mid, right) } else { right = mid; // 范围缩小到 (left, mid) } } return right; } } 颜色定义\r目标是判断 mid 的颜色，而颜色由 mid 与 target 的相对位置决定。\n二分查找（递增数组）：\nnums[mid] 是 target 或 target 右侧，染成蓝色；其余染成红色。\n找第一个满足 和最后一个满足\r第一个满足：常用左闭右开。 最后一个满足：常用左开右闭。 if (condition) 分支中通常只做 l = mid 或 r = mid，而不是 mid ± 1。 condition 的本质是下标关系，不是元素值关系。 在有序数组中，两者才会建立映射。 例如“查找第一个 \u0026gt;= target 的下标”，condition 可理解为“答案在 mid 左侧（含 mid）”。 // 返回第一个满足 condition 的下标，不存在则返回 n // fffttt static int firstTrue(int n) { int l = 0, r = n; // [l, r) while (l \u0026lt; r) { int mid = l + (r - l) / 2; if (condition(mid)) { //**target在mid左侧及mid** r = mid; // mid 可能是答案，收缩右边界 } else { l = mid + 1; // mid 不满足，排除 } } return l; } 最后一个满足：condition 可理解为 target.index \u0026gt;= mid。 例如“最后一个 \u0026lt;= 10”，此时 mid 满足条件就继续向右找。 // 返回最后一个满足 condition 的下标，不存在返回 -1 // tttffff static int lastTrue(int n) { int l = -1, r = n - 1; // (l, r] while (l \u0026lt; r) { int mid = l + (r - l + 1) / 2; // 注意 +1，向右取中点 if (condition(mid)) { // **target在mid右侧及mid** l = mid; // mid 满足，保留，向右找 } else { r = mid - 1; // mid 不满足，丢掉右半边 } } return l; } 中位数\r思路是找最后一个满足 left1 \u0026lt;= right2 的切分点。 二分范围是 [0, m]，写成左开右闭可设 l = -1, r = m，并在 if (condition) 中执行 l = mid。 class Solution { public double findMedianSortedArrays(int[] nums1, int[] nums2) { if (nums1.length\u0026gt;nums2.length) { int[] temp = nums1; nums1 = nums2; nums2 = temp; } int m = nums1.length; int n = nums2.length; // m+n : 奇数: m+n+1/2 , 偶数 m+n/2 合并 m+n+1/2个, int totalCount = (m+n+1)/2; // left1 是第一个数组左边最后一个, right1是第一个数组右边第一个, left2是第二个数组左边最后一个 // 现在进行二分查找, 针对第一个数组取多少个数进行二分查找, 范围是 [0,m], 我们发现 left1\u0026lt;=right2 , 一开始满足渐渐不满足了, // 因此我们要找最后一个满足的情况 因此 左开右闭 (-1,m] int left = -1; int right = m; while (left\u0026lt;right) { int i = left+(right-left+1)/2; int j = totalCount - i; int left1 = i==0?Integer.MIN_VALUE:nums1[0+i-1]; int right2 =j\u0026gt;=n?Integer.MAX_VALUE: nums2[j]; if (left1\u0026lt;=right2) { left = i; }else{ right = i-1; } } // left==right int i = left; int j = totalCount-i; int left1 = i==0?Integer.MIN_VALUE:nums1[i-1]; int right1 = i\u0026gt;=m?Integer.MAX_VALUE:nums1[i]; int left2 = j==0?Integer.MIN_VALUE:nums2[j-1]; int right2 = j\u0026gt;=n?Integer.MAX_VALUE:nums2[j]; if ((m+n)%2==0) { return (Math.max(left1, left2)+Math.min(right1, right2))/2.0; }else{ return Math.max(left1,left2); } } } ","date":"2026-04-11T00:00:00Z","permalink":"/p/%E4%BA%8C%E5%88%86%E6%9F%A5%E6%89%BE%E8%BE%B9%E7%95%8C%E5%86%99%E6%B3%95%E6%80%BB%E7%BB%93/","title":"二分查找边界写法总结"},{"content":"分治算法导论\rhttps://www.bilibili.com/video/BV1aN411d7Yi?p=8 算法设计与分析 - 北航童咏昕教授\n思路\r将大问题分解为小问题：小问题更容易看清本质，也更容易求解。\n得到的一般流程如下：\n复杂度\r递归树法\r树节点表示“本层复杂度”（不含下一层递归调用），子树表示下一层调用。因此，本层总复杂度可理解为：\n本层节点的数值 + 子树所有节点数值之和\n从初始规模 n 开始，一边每层除 4，一边每层除 4/3。除法次数更多的一侧决定递归树深度。这里深度可写为：max(log4(n), log4/3(n)) = log4/3(n)。\n代入法\rhttps://www.bilibili.com/video/BV1aN411d7Yi/?p=9\u0026spm_id_from=pageDriver\u0026vd_source=a9a24992f7f570a16d5a331e8fed9f0d\n","date":"2026-04-11T00:00:00Z","permalink":"/p/%E5%88%86%E6%B2%BB%E7%AE%97%E6%B3%95%E5%AF%BC%E8%AE%BA/","title":"分治算法导论"},{"content":"滑动窗口算法题型总结\r定长滑动窗口\r[4-1456 定长子串中元音个数](LeetCode 1456 定长子串中元音个数.md) [5-2461 长度为 k 子数组最大和](LeetCode 2461 长度为 K 子数组最大和.md) [1-3 无重复字符的最长子串](LeetCode 3 无重复字符的最长子串.md) [6-2653 滑动子数组的美丽值](LeetCode 2653 滑动子数组的美丽值.md) [13-2134 最少交换次数来组合所有的 1](LeetCode 2134 最少交换次数来组合所有的 1.md) [20-1052 爱生气的书店老板](LeetCode 1052 爱生气的书店老板.md) 不定长窗口：求最小\r[2-209 长度最小的子数组](LeetCode 209 长度最小的子数组.md) [11-1234 替换子串得到平衡字符串](LeetCode 1234 替换子串得到平衡字符串.md) [12-1574 删除最短子数组使剩余数组有序](LeetCode 1574 删除最短子数组使剩余数组有序.md) 不定长窗口：求最大\r8-1658 将 X 减到 0 的最小操作数 [9-1838 最高频元素的频数](LeetCode 1838 最高频元素的频数.md) [14-1004 最大连续 1 的个数](LeetCode 1004 最大连续 1 的个数.md) [15-2024 考试的最大困扰度](LeetCode 2024 考试的最大困扰度.md) [18-424 替换后的最长重复字符](LeetCode 424 替换后的最长重复字符.md) [23-1297 子串的最大出现次数](LeetCode 1297 子串的最大出现次数.md) 不定长窗口：求子数组个数\r[3-713 乘积小于 k 的子数组](LeetCode 713 乘积小于 K 的子数组.md) [7-2401 最长优雅子数组](LeetCode 2401 最长优雅子数组.md) [16-2962 统计最大元素出现至少 k 次的子数组](LeetCode 2962 统计最大元素出现至少 K 次的子数组.md) 重点题\r[11-1234 替换子串得到平衡字符串](LeetCode 1234 替换子串得到平衡字符串.md) [13-2134 最少交换次数来组合所有的 1](LeetCode 2134 最少交换次数来组合所有的 1.md) [16-2962 统计最大元素出现至少 k 次的子数组](LeetCode 2962 统计最大元素出现至少 K 次的子数组.md) [17-395 至少有 K 个重复字符的最长子串](LeetCode 395 至少有 K 个重复字符的最长子串 - 滑动窗口解法.md) [21-1156 单字符重复子串的最大长度](LeetCode 1156 单字符重复子串的最大长度.md) [23-1297 子串的最大出现次数](LeetCode 1297 子串的最大出现次数.md) [28-1888 使二进制字符串交替的最少反转次数](LeetCode 1888 使二进制字符串交替的最少反转次数.md) 思路\r核心套路是：先分类，再加“最优”约束，最后分析相邻窗口之间如何低成本更新。 这样做的本质是避免重复计算，后一个窗口通常只在前一个窗口基础上做有限修改。\n如果操作发生在数组两端，通常可以转化为“保留中间连续子数组”。 [2-209 长度最小的子数组](LeetCode 209 长度最小的子数组.md) 的窗口和既可以维护“大于等于 target”，也可以维护“小于 target”，两种写法都可行，区别在于答案更新时机。 [13-2134 最少交换次数来组合所有的 1](LeetCode 2134 最少交换次数来组合所有的 1.md) 的关键在于先确定答案窗口长度，再统计窗口内需要调整的元素。 滑动窗口通用检查表\r先假设窗口内就是候选答案。 明确限制条件： 长度限制。 窗口内元素约束（和、计数、种类数）。 优先从“失败条件”倒推滑动规则，必要时用极端样例验证。 结合分类思想：按左端点分类或按右端点分类。 补充：[16-2962 统计最大元素出现至少 k 次的子数组](LeetCode 2962 统计最大元素出现至少 K 次的子数组.md) 是“计数型”窗口，和“最值型”窗口在答案更新位置上有所不同。\n杂项\r长子串的出现次数不会超过其短前缀子串的出现次数，可用于剪枝：[23-1297 子串的最大出现次数](LeetCode 1297 子串的最大出现次数.md)。 ","date":"2026-04-11T00:00:00Z","permalink":"/p/%E6%BB%91%E5%8A%A8%E7%AA%97%E5%8F%A3%E7%AE%97%E6%B3%95%E9%A2%98%E5%9E%8B%E6%80%BB%E7%BB%93/","title":"滑动窗口算法题型总结"},{"content":"回溯算法题型总结\r方法\r先手动模拟解题流程， 得到一个不变的流程， 这个不变的流程就是递归函数的语义.\n[👍](LeetCode 77 组合 - 回溯解法.md)可以看到， 求解递归问题时可以先手动模拟得到 不变的流程， 上述题目中为 \u0026ldquo;不断地从某一区间选一个数加入到path中\u0026rdquo;， 这个不变的流程就是递归函数的语义.\n例如： [组合](LeetCode 77 组合 - 回溯解法.md)，模拟得到 {从某一区间内选一个数加入到path， 再从这个数下一位开始到max中选数加入path}是不变的， 不断重复这个不变的流程， 而变的只是 {从哪里开始}. 不变的应为函数的语义，这些变的应为函数的参数. 求集合，子集的问题函数的语义就是 从index 到最后选个值加入path，再从选的值后面开始再选;\r求分割的问题函数的语义就是 从index开始分割出来一部分加入path，再分割后面的.\r2026-01-24 在for循环中不要控制下一层参数的范围，留给下一层的return，比如单词搜索，for中不要看 newi newj 是否合法，留给下一层，当然顺序要对，先判断index直接返回true，再看其他 分类\r组合\r👍 函数语义：从index 到max 选数加入到path\n[组合](LeetCode 77 组合 - 回溯解法.md) [组合总和](LeetCode 39 组合总和 - 回溯解法.md) [组合总和2](LeetCode 40 组合总和 II - 回溯解法.md)\n分割\r👍 函数语义：从index开始切取前面一部分合法串加入到path，直到切完。\n[分割回文串](LeetCode 131 分割回文串 - 回溯解法.md) [复原ip地址](LeetCode 93 复原 IP 地址 - 回溯解法.md)\n子集\r👍 函数语义：从index到max选值加入path。 但和组合不同，这里不需要显式return。\n[子集](LeetCode 78 子集 - 回溯解法.md) [子集2](LeetCode 90 子集 II - 回溯解法.md)\n[递增子序列](LeetCode 491 递增子序列 - 回溯解法.md) ：函数语义：从index到最后选择一个合法值加入到path。\n排列\r👍 函数语义：从0到最后选之前未选过的值加入path。\n[全排列](LeetCode 46 全排列 - 回溯解法.md) [全排列2](LeetCode 47 全排列 II - 回溯解法.md)\n注意\r✖️[分割回文串](LeetCode 131 分割回文串 - 回溯解法.md) :在 if 判断的 return 位置不要对 path 进行 push 操作，因为没有与之对应 pop 操作， 这会导致 path pop不干净.\n✖️[子集](LeetCode 78 子集 - 回溯解法.md): 注意不要return，到最后一层，for循环不会执行， 隐式 return.\n✖️[递增子序列](LeetCode 491 递增子序列 - 回溯解法.md) ：last 不能满足去重了， 使用 use 数组。\n✖️[全排列](LeetCode 46 全排列 - 回溯解法.md)：设置全局变量，及时更新全局变量来控制 push哪些值。\n✖️[全排列2](LeetCode 47 全排列 II - 回溯解法.md)：重点在于去重， 有个bug：last 设置的位置。\n✖️❗[解数独](LeetCode 37 解数独 - 回溯解法.md)：直接在传入的参数上进行操作，不用再本地设置 path 等。\n为什么要设置 write 返回值 为 bool 而非 void 。 是如何防止正确的状态被重写的。 ","date":"2026-04-11T00:00:00Z","permalink":"/p/%E5%9B%9E%E6%BA%AF%E7%AE%97%E6%B3%95%E9%A2%98%E5%9E%8B%E6%80%BB%E7%BB%93/","title":"回溯算法题型总结"},{"content":"排序算法基础\r快速排序（划分+递归）\r快速排序通过一个划分函数将数组分成左右两段，后续分别递归排序，最终在原地完成整个序列的整理。下面的实现使用第一个元素作为 pivot，配合双向填充的方式把 pivot 送到最终的位置。\n初始划分函数\rhua_fen 会交替从左右两端往中间填充，直到左右指针相遇再把 pivot 放回最终位置并返回索引：\n#include\u0026lt;iostream\u0026gt; #include\u0026lt;vector\u0026gt; using namespace std; int hua_fen(vector\u0026lt;int\u0026gt;\u0026amp; nums,int left,int right){ int tmp = nums[left]; int seq = 0; // 0 向左填，1 向右填 while(true){ if(left == right){ break; } if(seq == 0){ while(right \u0026gt; left \u0026amp;\u0026amp; nums[right] \u0026gt;= tmp){ right--; } nums[left] = nums[right]; seq = 1; }else{ while(left \u0026lt; right \u0026amp;\u0026amp; nums[left] \u0026lt;= tmp){ left++; } nums[right] = nums[left]; seq = 0; } } nums[left] = tmp; return left; } void quick_sort(vector\u0026lt;int\u0026gt;\u0026amp; nums,int left,int right){ if(left \u0026gt;= right) return; int mid = hua_fen(nums,left,right); quick_sort(nums,left,mid-1); quick_sort(nums,mid+1,right); } int main(){ vector\u0026lt;int\u0026gt; nums = {8,2,3,4,7,4,5}; quick_sort(nums,0,nums.size()-1); for(auto\u0026amp; val:nums){ cout\u0026lt;\u0026lt;val\u0026lt;\u0026lt;endl; } } 划分策略优化\r双向循环时需要特别处理 l 与 r 的边界：\n从左侧找到第一个大于 pivot 的数，从右侧找到第一个小于 pivot 的数后交换。如果只写 l\u0026lt;r，那么当 l 提前移动到 r 的位置并跳出循环时，r 可能永远不会再次检查，随后的交换会把本应在左边的元素丢失（如 [4,3,2,4,4,1,5]）。 当 r\u0026lt;l 时，区间 [0,l-1] 都是小于等于 pivot 的数，[r+1,end] 都是大于等于 pivot 的数，交换一定要和 r 交换，否则左区间可能会出现大于 pivot 的值，破坏左闭右开区间的性质。 当 r==l 时，左右只剩一个位置，既可以用 r 也可以用 l 交换，但统一使用 r 更简单。 基于上述约束，我们可以把交换的判断写得更清晰：\nint hua_fen1(vector\u0026lt;int\u0026gt;\u0026amp; nums,int left,int right){ int pivot = nums[left]; int l = left + 1, r = right; while(true){ while(l \u0026lt;= r \u0026amp;\u0026amp; nums[l] \u0026lt;= pivot) l++; while(l \u0026lt;= r \u0026amp;\u0026amp; nums[r] \u0026gt;= pivot) r--; if(l \u0026gt;= r) break; swap(nums[l], nums[r]); l++; r--; } swap(nums[left], nums[r]); return r; } ","date":"2026-04-11T00:00:00Z","permalink":"/p/%E6%8E%92%E5%BA%8F%E7%AE%97%E6%B3%95%E5%9F%BA%E7%A1%80/","title":"排序算法基础"},{"content":"双指针算法题型总结\r两端收缩\r[1-167 两数之和 II](LeetCode 167 两数之和 II.md) [2-15 三数之和](LeetCode 15 三数之和.md) [3-11 盛水最多的容器](LeetCode 11 盛水最多的容器.md) 隐形挡板\r[4-42 接雨水](LeetCode 42 接雨水 - 双指针解法.md) [8-31 下一个排列](LeetCode 31 下一个排列.md) 思路\r双指针题常见的关键是：先定义“当前状态”，再决定“下一步向哪边移动”。 动态规划通常是“由前推后”，双指针更多是“由当前状态剪枝后继续前进”。\n以 [3-11 盛水最多的容器](LeetCode 11 盛水最多的容器.md) 为例：每次都在相邻状态里选择更有机会变优的方向，这就是双指针能降复杂度的核心。\n","date":"2026-04-11T00:00:00Z","permalink":"/p/%E5%8F%8C%E6%8C%87%E9%92%88%E7%AE%97%E6%B3%95%E9%A2%98%E5%9E%8B%E6%80%BB%E7%BB%93/","title":"双指针算法题型总结"},{"content":"微服务分布式事务\r原因 解决思路 docker 部署\r添加数据库表 准备配置文件 docker run docker run --name seata -p 8099:8099 -p 7099:7099 -e SEATA_IP=192.168.87.128 -v ./seata:/seata-server/resources --privileged=true --network hmall -d seataio/seata-server:1.5.2 验证 微服务继承seata\r引入依赖 \u0026lt;!--统一配置管理--\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.alibaba.cloud\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-cloud-starter-alibaba-nacos-config\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!--读取bootstrap文件--\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.cloud\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-cloud-starter-bootstrap\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!--seata--\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.alibaba.cloud\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-cloud-starter-alibaba-seata\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; 配置 ","date":"2026-04-11T00:00:00Z","permalink":"/p/%E5%BE%AE%E6%9C%8D%E5%8A%A1%E5%88%86%E5%B8%83%E5%BC%8F%E4%BA%8B%E5%8A%A1/","title":"微服务分布式事务"},{"content":"微服务服务保护\r雪崩问题\r保护方案\r请求限流\r线程隔离\r服务熔断\r解决方案\rsentinal\rhttps://b11et3un53m.feishu.cn/wiki/QfVrw3sZvihmnPkmALYcUHIDnff\n","date":"2026-04-11T00:00:00Z","permalink":"/p/%E5%BE%AE%E6%9C%8D%E5%8A%A1%E6%9C%8D%E5%8A%A1%E4%BF%9D%E6%8A%A4/","title":"微服务服务保护"},{"content":"微服务架构拆分与远程调用\r认识微服务\r单体架构: 将所有业务功能集中在一个项目中开发, 打成一个包部署 架构简单, 部署简单 团队协作成本高, 系统发布效率低, 系统可用性差 适合功能简单, 规模较小的项目 拆分\r拆分时机\r如何拆分\r模式\r独立 Project maven 聚合 大型项目可以使用 Project 模式, 中小型项目使用 Maven模式\ndemo-黑马商城\r使用 Maven 聚合模式 https://www.bilibili.com/video/BV1S142197x7?vd_source=a9a24992f7f570a16d5a331e8fed9f0d\u0026amp;spm_id_from=333.788.player.switch\u0026amp;p=44 远程调用\rrestTemplate\rcart 中要使用 item-service // 服务拆分之后就不能直接调用了 // List\u0026lt;ItemDTO\u0026gt; items = itemService.queryItemByIds(itemIds); // 2.1 利用 resttemplate 获取响应 ResponseEntity\u0026lt;List\u0026lt;ItemDTO\u0026gt;\u0026gt; ids = restTemplate.exchange( \u0026#34;http://localhost:8081/items?ids={ids}\u0026#34;, HttpMethod.GET, null, new ParameterizedTypeReference\u0026lt;List\u0026lt;ItemDTO\u0026gt;\u0026gt;() { }, Map.of(\u0026#34;ids\u0026#34;, CollUtils.join(itemIds, \u0026#34;,\u0026#34;)) ); if(!ids.getStatusCode().is2xxSuccessful()){ return; } List\u0026lt;ItemDTO\u0026gt; items = ids.getBody(); 注册中心\rnacos\r数据库中先执行 nacos.sql 运行 docker version: \u0026#34;3.8\u0026#34; services: mysql: image: mysql container_name: mysql ports: - \u0026#34;3306:3306\u0026#34; environment: TZ: Asia/Shanghai MYSQL_ROOT_PASSWORD: 123 volumes: - \u0026#34;./mysql/conf:/etc/mysql/conf.d\u0026#34; - \u0026#34;./mysql/data:/var/lib/mysql\u0026#34; - \u0026#34;./mysql/init:/docker-entrypoint-initdb.d\u0026#34; networks: - hm-net hmall: build: context: . dockerfile: Dockerfile container_name: hmall ports: - \u0026#34;8080:8080\u0026#34; networks: - hm-net depends_on: - mysql nginx: image: nginx container_name: nginx ports: - \u0026#34;18080:18080\u0026#34; - \u0026#34;18081:18081\u0026#34; volumes: - \u0026#34;./nginx/nginx.conf:/etc/nginx/nginx.conf\u0026#34; - \u0026#34;./nginx/html:/usr/share/nginx/html\u0026#34; depends_on: - hmall networks: - hm-net nacos: image: nacos/nacos-server:v2.1.0-slim container_name: nacos2 env_file: - ./nacos/custom.env ports: - 8848:8848 - 9848:9848 - 9849:9849 restart: always networks: - hm-net networks: hm-net: name: hmall springBoot 添加依赖 \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.alibaba.cloud\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-cloud-starter-alibaba-nacos-discovery\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; 查看结果 http://192.168.87.129:8848/nacos/\r用户和密码都是 nacos 服务发现 购物车服务中\n// 1.获取商品id Set\u0026lt;Long\u0026gt; itemIds = vos.stream().map(CartVO::getItemId).collect(Collectors.toSet()); // 2.1 根据服务名称获取服务实例列表 List\u0026lt;ServiceInstance\u0026gt; instances = discoveryClient.getInstances(\u0026#34;item-service\u0026#34;); if(instances.isEmpty()){ return; } // 2.2 根据策略选择一个实例 ServiceInstance serviceInstance = instances.get(RandomUtil.randomInt(instances.size())); // 2.查询商品 // TODO 获取数据 // List\u0026lt;ItemDTO\u0026gt; items = itemService.queryItemByIds(itemIds); // 2.1 利用 resttemplate 获取响应 ResponseEntity\u0026lt;List\u0026lt;ItemDTO\u0026gt;\u0026gt; ids = restTemplate.exchange( serviceInstance.getUri()+ \u0026#34;/items?ids={ids}\u0026#34;, HttpMethod.GET, null, new ParameterizedTypeReference\u0026lt;List\u0026lt;ItemDTO\u0026gt;\u0026gt;() { }, Map.of(\u0026#34;ids\u0026#34;, CollUtils.join(itemIds, \u0026#34;,\u0026#34;)) ); openfeign\r在 nacos 基础上省去了 resttemplate 获取请求的代码 openFeign 连接池\ropenFeign 最佳实践\r效果很好, 大型服务可以使用 结构复杂, 增加了工作量 如果每个微服务是一个 Project 可以使用这种方式 代码耦合度增加了 如果微服务是通过 maven聚合实现的, 可以使用这种方法 demo\r因为 cart 服务的包是 com.hmall.cart , 而 client 在 com.hmall.api.client , 因此不会扫描到, 所以\nopenFeign 日志\r总结\r作业\r拆包出现跨服务调用的情况, 则外部服务的 controller 中要有对应的接口\n将 controller 中的方法导入 api-service 中的 client 中 删掉当前包 trade 下的 dto, 引入 api-service 中的 dto 问题\rclient 找不到 网关\r如何解决用户身份的问题, 用户登录是 user-service , 其他服务并不知道用户身份 端口问题, 端口很多, 前端要访问哪个端口 路由断言\r路由过滤\r","date":"2026-04-11T00:00:00Z","permalink":"/p/%E5%BE%AE%E6%9C%8D%E5%8A%A1%E6%9E%B6%E6%9E%84%E6%8B%86%E5%88%86%E4%B8%8E%E8%BF%9C%E7%A8%8B%E8%B0%83%E7%94%A8/","title":"微服务架构拆分与远程调用"},{"content":"微服务配置管理\r共享配置\rnacos 添加配置 引入依赖 编写 bootstrap.yml\n拉取配置 spring: application: name: cart-service # 服务名称 profiles: active: dev cloud: nacos: server-addr: 192.168.150.101 # nacos地址 config: file-extension: yaml # 文件后缀名 shared-configs: # 共享配置 - dataId: shared-jdbc.yaml # 共享mybatis配置 - dataId: shared-log.yaml # 共享日志配置 - dataId: shared-swagger.yaml # 共享日志配置 配置热更新\r动态路由\r","date":"2026-04-11T00:00:00Z","permalink":"/p/%E5%BE%AE%E6%9C%8D%E5%8A%A1%E9%85%8D%E7%BD%AE%E7%AE%A1%E7%90%86/","title":"微服务配置管理"}]