胶东半岛观察

一面法定潮汐表,丈量着胶东半岛的野性与文明。 威海 一座偎在胶东半岛角落的精致小城。裙边是绿白清秀的不成连绵的矮山,前摆是弧度不那么规整的有小屿突触的海岸线。这种不加雕琢的美,像某个记忆里的僻如坪山碧岭的海滨小镇,安静而耐人寻味。 未经雕琢的安静被网红流量打破。无人机如新的候鸟群盘旋,三脚架在旧的观景点扎根。火炬八街的午后,人潮比海浪更懂得重复的艺术。而角落熙攘的垃圾桶,只是无声地装着鲜花、雨衣、折断的高跟鞋——显然它们没有海鸥挑食。随着潮汐退去又涨来,可知潮汐才是这片海岸真正的主人。 而我,也是这潮汐里的一滴水,可惜遵循的是法定潮汐表,而非岸边张贴的。 烟台 养马岛的海,是一块被时光遗忘的果冻。它不求深邃,却以清澈自持,让人相信海也可以是天然的泳池。这种坦诚,与威海的含蓄保持着默契的距离,正如牟平与威海的距离。它们像半岛伸出的两只手:一只捧着未经雕琢的安静,另一只,则盛着这汪见底的透明。 至于长岛的潮汐,我尚未赴约。或许下次,等我能分清,哪一滴水声来自太平洋,哪一声又只是我手机里定时响起的行程提示音。 青岛 青岛字如其名长青之岛。CBD的夜色,是资本与潮汐共同浇筑的结晶。五十层高空俯视,霓虹如血管般在楼宇间奔流,勾勒出金融与欲望的拓扑图。远处吊塔的红色信号灯明灭,像为这片填海而生的新城打着节拍——一种被精密计算的、永不疲倦的潮汐。 而浮山森林公园则扮演着它的反面。那里的山海如未被驯服的旧梦,松涛与海浪合谋,试图淹没来自CBD的电子脉冲。山路蜿蜒如静默的抵抗,提醒着人们:在成为国际湾区之前,青岛首先是一座岛。 真正的“人间”则散落在海边任何一寸可供落座的土地。无需帐篷,不论晨昏,支一把露营椅,男女老少便能面朝大海,为自己辟出一席之地。这并非精致的野趣,而是一种更朴素的与故里海浪的交流。 真正的“雕琢”藏在街角。道路护栏外侧悄然延展出休憩的桌椅,桌面之下,无线充电线圈正发出温柔的磁力。这已超越了便民服务,更像一场城市与过客达成的微妙契约:你为我停留,我予你能量。正如栈桥旁的太阳能座椅,白昼吸吮阳光,夜晚则为听海的过客释放些许光明——赛博朋克的光合作用。 在这座城,自然与科技并非对峙,而是达成了某种共谋。山海是底色,代码是笔触,金融是驱力,共同书写着一份既野性又文明的“法定潮汐表”。而每一位过客、每一个集装箱,既是观潮者,也是被计量的水滴。 结语 没参考攻略,一次极其简单的胶东半岛旅行观察。归程时蓝调被朝阳划破,麦田被高铁划破,两股潮汐被工作划破。明明远离了海边,那若隐若现的熙熙攘攘的潮汐却跟了一路,回到天津,更甚。原来都是回到自己的岛屿,在各自的法定表格上,一次次签到与签退,迎接岛屿的下一次潮汐。 附录: 旅拍 详细相册地址https://www.picgo.net/album/ksZEr

2025/10/8
articleCard.readMore

海光 K100 DCU VLLM 推理环境构建

系统环境 系统: Kylin OS 芯片: 128H, Hygon C86 7390 2S * 64 显存: 128G, Hygon K100 DCU 64G * 2 内存: 500G 基础驱动 PS: 详情参考 DTK环境安装与部署 DTK: 最新DTK列表 解压: tar -xzvf DTK*.tar.gz 载入环境: cd DTK* && source env.sh 测试: hipcc --version 驱动: 最新驱动列表 执行: ./rock* 测试: hy-smi 部署资源 模型文件 可以从任意平台下载 vllm 所需要的离线模型文件,以下举例 HF、魔搭下载。 强烈建议像示例一样小模型测试,启动快看报错也快。 HuggingFace PS: 也可以选择Git命令下载: git clone https://hf-mirror.com/deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B HF下载模型 1 2 3 4 5 6 7 8 9 10 11 # 基于PY环境安装HF脚手架 pip install huggingface-hub # 镜像加速 $HF_ENDPOINT = "https://hf-mirror.com" # 下载完整模型 huggingface-cli download --repo-type model \ deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B \ --local-dir ./DeepSeek-R1-Distill-Qwen-1.5B \ --local-dir-use-symlinks False ModelScope(国内) PS: 也可以选择Git命令下载: git clone https://www.modelscope.cn/deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B.git 魔搭下载模型 1 2 pip install modelscope modelscope download --model deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B --local_dir ./DeepSeek-R1-Distill-Qwen-1.5B 环境镜像 与其它卡有所不同,因缺少 CDNA/GCN 架构的优化内核、未针对 Hygon 芯片做算子优化等原因,国产加速卡需要使用定制的镜像。 镜像按照自己的DCU驱动版本选择: 光源定制镜像 。 离线内网环境请先准备好镜像包导入。 部署服务 参数详情: HIP_VISIBLE_DEVICES: 使用的显卡槽 HSA_OVERRIDE_GFX_VERSION: 匹配K100架构 --tensor-parallel-size: 使用显卡数量 --gpu-memory-utilization: 显卡使用率 编排文件: 请自行修改以下内容 environment里显卡数、显卡槽 command里model路径 volumes里映射的模型路径实际路径 docker-compose.yml 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 version: "3.9" services: vllm: image: image.sourcefind.cn:5000/dcu/admin/base/pytorch:2.4.1-ubuntu22.04-dtk25.04.1-py3.10 container_name: vllm-test environment: - HIP_VISIBLE_DEVICES=0,1 - HSA_OVERRIDE_GFX_VERSION=10.3.0 command: > python3 -m vllm.entrypoints.openai.api_server --model /workspace/models/DeepSeek-R1-Distill-Qwen-1.5B --tensor-parallel-size 2 --gpu-memory-utilization 0.9 --served-model-name ds-r1-1.5b --dtype float16 --trust-remote-code --enforce-eager --host 0.0.0.0 --port 8000 network_mode: host ipc: host devices: - "/dev/kfd:/dev/kfd" - "/dev/dri:/dev/dri" volumes: - /opt/hyhal:/opt/hyhal:ro - /workspace/service/vllm/models:/workspace/models:ro cap_add: - SYS_PTRACE security_opt: - seccomp=unconfined group_add: - video - render restart: unless-stopped 测试命令 一些打印测试的命令,成功部署可以忽略。 测试服务是否正常运行 1 2 3 4 5 6 7 8 9 10 11 curl -X POST "http://127.0.0.1:8000/v1/completions" \ -H "Content-Type: application/json" \ -d '{ "model": "ds-r1-1.5b", "prompt": "Compute the Fourier transform of the constant function f(t) = 1. What should the correct answer be?", "max_tokens": 50, "temperature": 0.7 }' # ====理论输出==== # answer is 2πδ(ω) 查看容器对显卡的识别 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 python3 - <<'PY' import torch print("PyTorch 版本:", torch.__version__) print("是否可见 HIP/GPU:", torch.cuda.is_available()) print("GPU 数量:", torch.cuda.device_count()) for i in range(torch.cuda.device_count()): print(f"设备 {i} 名称:", torch.cuda.get_device_name(i)) PY # ====理论输出==== # PyTorch 版本: 2.5.1 # 是否可见 HIP/GPU: True # GPU 数量: 2 # 设备 0 名称: K100 # 设备 1 名称: K100

2025/8/30
articleCard.readMore

博客多平台负载均衡方案

折中式 博客多平台负载均衡方案 前言 此前博客在 Vercel 托管,域名是腾讯云 CNAME 到 Vercel 的加速源,但是最近发现这个方案有如下问题: 神秘力量:有时网络只位于墙内或墙外,单向访问 额度耗尽:Vercel 的免费额度是 100G/月,这个月博客居然把额度耗尽 容灾能力:受单平台限制没有灾害转移能力,包括不限于对网络问题、额度问题的处理 基于以上问题,前天计划周末进行多平台负载均衡,增强博客的稳定性与白嫖性。虽然不能完美解决,但已经得到很大改善。 而且该方案可复用于无状态、一致性的项目,如:博客、文档、SPA、只读类接口、静态加速等等。毕竟额度不够,平台账号数量来凑。 问题 考虑到该方案的可能缺陷,如统计数据、SEO易混乱、部分平台行为不一致、首次 TLS 握手稍慢等等,未来将在此处记录出现的问题与解决方案。 2025-06-21 使用多平台负载均衡方案 方案 准备过程 服务器:可以是微型服务器,只用来运行负载均衡程序。最好是香港服务器防止国内网络问题。 域名:可以是任何域名,但需要解析到服务器的 IP 地址。 负载均衡程序:可以是任何具备负载均衡程序,如 Nginx、OpenResty 等。 多平台账号:Vercel、Netlify、Cloudflare 等。 设计思路 部署博客在Vercel等多平台,不需要配置DOMAIN,用默认的域名即可。 Hexo等框架通常生成一个静态的 public 文件夹,上传到 Git 仓库中在 Vercel、GitHub Pages、Cloudflare Pages、Netlify 等引用仓库部署。(后续推送会自动触发更新) 需要的话服务器本身也可以托管一份 域名解析到服务器的IP地址 服务器安装负载均衡程序,自行搜索《Linux安装Nginx》之类的教程。 签发证书,新建站点,并配置规则。(配置完无需维护,除非部署平台有增减) 详细教程 https://blog.thatcoder.cn/wiki/LoadBalance/deploy/config.htmlhttps://blog.thatcoder.cn/wiki/LoadBalance/deploy/config.html 视频教程 因为对于服务器新手配置可能过于宽泛与繁琐,录制了视频作为参考。 效果 网站测速 国内 海外 博客国内访问效果 博客海外访问效果 必应索引 测试了必应站点地图索引与网站扫描与往常一致。 局限性与解决思路 需要自建服务器 维护成本高于单纯用平台白嫖部署,但可以选择轻量 VPS(1H/1G 足矣)。 不具备主动健康检查 负载均衡程序本身不具备主动健康检查功能,无法动态剔除故障节点,只能故障转移。可通过 max_fails + 定时 reload 替代;未来可引入 Lua 脚本来实现。 SEO问题 多副本可能被搜索引擎识别为重复,可通过添加 canonical标签、站点地图等指向源站。(Stellar已实现) 部分平台行为不一致 如平台对404错误处理不一致,可添加自定义 404 页面。(Stellar已实现)

2025/6/21
articleCard.readMore

浅谈RAG

RAG是权衡LLM的发散性与其准确性而诞生的产物 为何存在 RAG(Retrieval-Augmented Generation,检索增强生成) 诞生: 解决基础LLM的三个核心短板: 知识固化: 训练数据固定,无法动态更新知识,导致的知识时效性问题。 知识不足: 对冷门、专业领域、机密等特性的知识掌握有限。 事实幻觉: 生成看似合理但完全虚构的内容,比之更头疼的是混杂性幻觉。 价值: 低成本控制基础LLM: 数据可控: 将私有数据纳入检索库,避免敏感数据泄露给第三方基础模型。 引用追溯: 生成的答案附带检索到的参考文档,方便验证可信度与追溯来源。 成本效益: 相比微调大模型,RAG成本降低80%。 秒级更新: 允许秒级更新知识(股票、价格),而LLM微调需小时级耗时。 设计思想 RAG架构更像一位“学者”,在模仿人类认知双系统(快思考/慢思考)。先查阅文献,再写论文,而非仅凭记忆吃老本。 RAG的本质是将信息检索与文本生成结合,通过动态注入外部知识来增强LLM的能力。其核心逻辑是: 检索阶段:从海量私有数据中精准筛选与问题相关的片段。 生成阶段:LLM基于检索结果生成答案。 技术实现 RAG技术架构图 RAG技术架构图 准备阶段 数据准备: 将私有准备的各类型数据利用分块技术进行切分。 数据向量化: 用嵌入模型将分块向量化。 数据落盘: 向量存入向量数据库,建立高效检索索引。 检索阶段 用户输入问题 → 转换为Embedding → 在向量库中搜索Top-K相似片段。 结合多模块检索、多跳检索、重排序、BM25等技术,提升召回的准确率。 生成阶段 将检索到的文档片段作为上下文,与用户问题一起输入LLM。 调优Prompt限制LLM发散性提高准确性。 企业落地 三甲医院:智能医患问答系统 实施方: 深圳市第三人民医院 方案: DeepSeek+RAG动态增强系统,知识来源300万条临床数据与52万字院内指南 效果: 患者满意度97%,节省医生73小时/月(截至2025年2月) 来源: 深圳三院AI健康管家搭载DeepSeek,秒级响应守护您的健康 测绘院:实景三维知识库 实施方: 湖南省自然资源厅直属单位 方案: 基于DeepSeek大模型+RAG构建专业地理信息知识库 效果: 提升测绘数据语义化检索效率,支持智能解析 来源: 省第一测绘院开展实景三维知识库建设与应用培训 - 湖南省自然资源厅 华夏银行:数字金融风控系统 实施方:全国性股份制商业银行 方案:RAG+Agent技术实现企业注销风险预测模型 效果:自动化构建判别式AI模型,提升风控效率 来源:华夏银行吴永飞等:大语言模型打开银行数字金融发展新思路 未来思考 既然开头说了是权衡的产物,那么发散性与准确性的平衡被打破时,RAG必将面临一个退位局面。 当LLM或者说另一种新的M突破知识固化与幻觉瓶颈时,RAG的“检索增强”功能可能逐渐隐入幕后,很多维护的LLM增强型RAG可能失去其存在的意义。 当然我没看空RAG,秒级更新与数据可控是无法替代。 在我看来未来王者退位,但荣光依旧。RAG不再以“独立技术”存在,但其设计思想会融入LLM架构,形成更智能的自我检索机制,成为LLM的“标准”之一。RAG不是过渡技术,而是人机协作的范式,RAG永远是LLM的移动硬盘。

2025/6/15
articleCard.readMore

Web技术构建桌面应用方案

桌面应用方案 从Electron、Tauri、Flutter、pkg四个方案比较,打包复杂度中小web项目为例(vue构建结果10MB左右) 下表列出 Electron、Tauri、Flutter、pkg 四种方案在关键变量下的特性对比: 抉择变量ElectronTauriFlutterpkg 支持平台Windows/macOS/Linux (跨平台)Windows/macOS/Linux (跨平台)Windows/macOS/Linux(跨平台)Windows/macOS/Linux(依赖Node) 启动速度较慢(典型示例约4秒)较快(示例约2秒)一般(取决于硬件,Dart AOT编译)较快(纯Node环境,无浏览器启动开销) 内存占用较高(空闲时约100MB+)较低(空闲时约80MB)低(简单应用约25MB)较低(无UI时几十MB,不含浏览器进程) CPU负载较高(多进程架构、Chromium开销)较低(Rust后端+系统WebView)低(编译为原生码、使用GPU加速)低(单进程Node,轻量运行) 打包体积较大(包含Chromium+Node,例如示例约244MB)很小(示例约8.6MB)中等(包含Flutter引擎,通常几十MB)中等(包含Node运行时,几十MB) 内置运行时内置 Chromium 和 Node.js不内置Node.js,使用系统 WebView 引擎内置 Dart VM(编译为本地二进制)内置 Node.js 运行时 运行环境依赖无需额外环境(Chromium已打包)需要目标系统提供对应 WebView(Win: WebView2;Linux/Mac: WebKit)需要目标系统对应的图形库和编译环境无需预装Node.js(运行时已包含在可执行文件中) 构建资源需求中等(需要安装Node依赖,下载Electron二进制)较高(需要安装Rust工具链,首次编译耗时较长)较高(需安装Flutter SDK及桌面支持工具)较低(仅需Node环境和pkg工具) 前端兼容性完全支持任意Web前端(Vue、React等)完全支持任意Web前端(Vue、React等)不使用HTML/JS,仅支持Flutter/Dart组件无原生前端,仅打包Node后台逻辑,不自带GUI 原生功能集成丰富的Electron API(窗口、托盘、通知等)+Node插件支持提供Rust后端API和插件(窗口、文件系统、托盘等,需显式暴露)通过插件或平台通道访问原生(文件系统、窗口管理、托盘可用第三方库)受限于Node能力,可调用系统命令或Node模块,通常用于CLI或后台逻辑 安全性中等(默认开启Node集成会增大风险;需严格启用Context Isolation等安全策略)高(默认安全模型严格,需要显式暴露API;Rust内存安全)良好(编译为原生,可执行文件难以反编译,但需自行管理应用权限)中等(打包后源代码不可见,有一定保护;无内置更新机制) 适用场景适合快速开发的跨平台富GUI应用,如桌面客户端工具、大型桌面应用适合对包体积和性能敏感的桌面应用,如小型工具、系统实用程序、高安全性需求的应用适合需要高性能UI和动画交互的应用,如游戏、多媒体应用或跨移动+桌面项目适合命令行工具或后台常驻程序,如自动化脚本、本地服务器等(不依赖图形界面) 注: 上表中的性能数据和包体积等来自 公开基准测试 简要分析 Electron Electron 基于 Chromium 及 Node.js 运行时,支持 Windows、macOS、Linux 三大平台。它将 Web 应用封装为桌面应用,对于习惯 Web 开发的程序员来说,上手简单,功能强大。 性能方面:启动时间通常在几秒左右,内存与 CPU 消耗较高——Windows 下测试显示空闲状态约消耗 120 MB 内存。 包体积方面:整合 Chromium 和 Node,使得最终体积通常为几百 MB。 依赖方式:打包时已将所有运行时一并内置,终端用户无需额外配置。 前端兼容性:可任意使用 Vue、React 等现代框架。 原生接口:提供如窗口控制、系统托盘、文件访问、通知等丰富 API,并可直接使用 Node 模块。 安全性:默认允许主进程完全访问 Node,会带来潜在风险——推荐启用 contextIsolation、预加载脚本等安全策略。 更新机制:常见方案为 electron-updater 与 GitHub Releases 的结合,实现自动更新。 总结:生态成熟、开发快速,适合需要大量 Web 交互、复杂界面的大型应用,但在包体体积和运行效率上存在较大代价。 Tauri Tauri 后端采用 Rust,界面部分使用操作系统自带的 WebView(例如 Win 的 WebView2,Linux/macOS 的 WebKit),实现了小巧和高效的目标。 性能方面:启动速度快,实测约 2 秒;Windows 空闲内存约 80 MB,多窗口下整体占用约 170 MB。 包体积方面:经测试仅约 8.6 MiB。 依赖方式:最终需要系统中预装相应 WebView 运行时。 开发成本:需要安装 Rust 工具链,首次编译时间较长,但后续增量编译迅速。 兼容性:支持 Vue、React 等任意 Web 技术。 原生接口:提供可控的 Rust 插件体系,默认不开放危险 API,提高安全性。 安全性:默认启用 CSP 和权限许可机制,攻击面极小。 更新机制:可内置轻量自更新模块,结合 JSON、HTTP 等方案完成。 总结:安全、高效、体积极小,适合轻量型或系统级工具,但对 Rust 生态掌握有所要求,社区相对年轻。 Flutter Flutter 使用 Dart 编写,通过自带的 Skia 渲染引擎生成原生界面。桌面支持 Windows、macOS、Linux。 性能方面:编译为本地执行码,UI 流畅,GPU 加速友好。Linux 测试显示,轻量应用占用约 25 MB 内存、50 MB 磁盘,比 Electron 更轻量。 包体积方面:需打包 Flutter 引擎,“Hello World” 即超过 50 MB。 依赖方式:需要目标平台对应的 Flutter 运行库及工具链支持。 开发成本:需安装 Flutter SDK 和桌面构建插件,环境搭建稍重。 兼容性:不得使用 JS 框架,必须采用 Dart + Flutter。 原生接口:通过 plugin 或 platform channels,可调系统功能。 安全性:源码被编译,逆向相对困难;但需自行实现更新机制。 总结:适合 UI 复杂、动画丰富、还需移动+iOS支持的项目,不建议用于简单工具或仅 Web 前端项目。 pkg pkg 将 Node.js 应用打包为可执行文件的工具,支持 Windows/macOS/Linux。 性能方面:性能接近原生 Node.js,启动迅速。 包体积方面:包含 Node 运行时,体积几十至一百多 MB,介于脚本与 Electron 之间。 依赖方式:无需用户预装 Node 环境。 开发成本:仅需 Node/npm 环境,配置简单。 兼容性:不支持 GUI,适用于 CLI 或后台业务。 功能支持:可调用任意 Node 模块与系统命令,适合自动化、脚本工具。 安全性:源码被打包,具基本保护,但无自动更新机制。 总结:最适合命令行工具、本地后台服务等无需前端的项目,不支持桌面 GUI。 基于项目类型推荐 Electron:适合需要快速开发、依赖丰富Web生态的大型跨平台桌面应用(如聊天客户端、IDE、管理工具等)。对于开发者熟悉Web栈的项目,Electron可实现复杂功能,但会带来较大包体和运行时开销。 Tauri:适合对应用体积、安全和性能敏感的场景,如系统实用工具、轻量级编辑器或企业级安全应用。Tauri 能制作极小的可执行文件,运行内存低,并内建安全策略。 Flutter:适合对UI/动画要求高的应用,如图形化界面、游戏、多媒体工具或需要同时覆盖移动和桌面的项目。Flutter 的原生性能强劲,但包体较大,且开发需使用 Dart 生态。 pkg:适合纯后端或命令行型工具(无需GUI),例如自动化脚本、CLI工具和后台服务。它可以打包Node应用为独立可执行文件,方便分发和部署。 除非真的只打包基于node的工具类,不轻易推荐 pkg. 非特定Dart编写, 即正常web项目推荐rust驱动的tauri打包构建. 亲身经验在占用和丝滑度来说尚佳! 构建参考 贴几个参考,然后再补构建参考教程嘞,又是拖更的一天 Electronhttps://www.mryunwei.com/379116.html Taurihttps://segmentfault.com/a/1190000046020931 Flutterhttps://docs.flutter.cn/reference/learning-resources

2025/6/8
articleCard.readMore

Spring WebSocket 错误

1. JSR-356 容器握手失败 现象 控制台没有任何 afterConnectionEstablished 日志 客户端卡在握手阶段或报错超时 原因 Spring MVC 默认用 StandardWebSocketClient(),但未指定底层 WebSocketContainer 嵌入式 Tomcat(JSR-356 实现)与默认容器不匹配,导致握手不发起 排查 & 解决 显式注入 JSR-356 容器: 1 2 3 4 5 @Bean fun webSocketClient(): WebSocketClient { val container = ContainerProvider.getWebSocketContainer() return StandardWebSocketClient(container) } 或干脆切换到 Reactor Netty 客户端: 1 2 3 4 @Bean fun webSocketClient(): WebSocketClient { return ReactorNettyWebSocketClient() } 启动时应看到类似日志: 1 Downstream connection established for session: <id> 实在不行web等依赖都排除tomcat,自行在web依赖加上exclusions: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 <!-- 1. Spring Boot Web,但排除默认 Tomcat --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <exclusions> <!-- 排除 Tomcat 嵌入式容器 --> <exclusion> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-tomcat</artifactId> </exclusion> </exclusions> </dependency> <!-- 2. 专门引入 Undertow 及其 JSR-356 支持 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-undertow</artifactId> </dependency> <!-- Undertow 对 JSR-356 WebSocket 的实现 --> <dependency> <groupId>io.undertow</groupId> <artifactId>undertow-websockets-jsr</artifactId> <!-- 可根据 Spring Boot 版本选择合适版本,通常与 Spring Boot 兼容即可 --> </dependency> <!-- 3. Spring WebSocket 模块,用于控制握手与消息处理 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> <!-- 注意:Spring Boot Starter WebSocket 本身会带 Tomcat 的 WebSocket 支持, 但由于我们已经排除了 Tomcat Starter,这里只会引入 spring-websocket 相关依赖,不会再带 tomcat-embed-websocket --> </dependency> 2. 路由没生效 现象 配置了 WebSocketConfigurer,但 afterConnectionEstablished 并未触发 日志中不见任何 /your/path 映射信息 原因 构造函数注入 List<ISocketHandler> 导致循环依赖 Spring Boot 2.6+ 默认禁用循环引用,Bean 未注册 或者忘记 @EnableWebSocket 排查 & 解决 确保配置类加上: 1 2 3 4 5 @Configuration @EnableWebSocket class WebSocketConfig( @Lazy private val handlers: List<ISocketHandler> ) : WebSocketConfigurer { … } 日志应包含: 1 Mapping “[/{registerPath}]” to WebSocketHandler 3. API 弃用与签名变化 3.1 Unresolved reference: handshake / execute 提示 1 2 Unresolved reference 'handshake' Unresolved reference 'execute' 解读 StandardWebSocketClient 使用 doHandshake(...) 而非 execute execute() 属于 WebFlux Reactor 客户端,与 MVC 客户端不通 修正 MVC 客户端: 1 2 client.doHandshake(handler, uri) .addCallback({ sess -> … }, { ex -> … }) Reactor 客户端: 1 2 3 client.execute(URI.create(uri)) { wsSession -> … }.subscribe() 3.2 doHandshake(...) 自 6.0 起弃用 提示 1 'doHandshake(WebSocketHandler, String, Object...)' 自版本 6.0 起已弃用并标记为移除 说明 Spring Framework 6 推荐注入 WebSocketContainer 或切换到 Reactor Netty 暂可忽略警告,或升级为新版推荐 API 4. 依赖冲突 & 类加载 典型报错 1 2 ClassNotFoundException: javax.websocket.ContainerProvider NoSuchMethodError: jakarta.websocket.ContainerProvider.getWebSocketContainer() 原因 Spring Boot 3.x 使用 jakarta.websocket,2.x 使用 javax.websocket 嵌入式 Tomcat/WebSocket API 版本与项目引入冲突 解决 统一依赖至一个版本,且与 Spring Boot 主版本匹配 必要时在依赖中排除冲突项: 1 2 3 4 5 6 7 8 9 10 <dependency> <groupId>org.apache.tomcat.embed</groupId> <artifactId>tomcat-embed-websocket</artifactId> <exclusions> <exclusion> <groupId>jakarta.websocket</groupId> <artifactId>jakarta.websocket-api</artifactId> </exclusion> </exclusions> </dependency> 5. 其他常见错误 错误类型典型现象 / 异常排查要点 循环依赖BeanCreationException: circular reference使用 @Lazy 或拆分配置 无效 JSONJsonParseException: Unexpected character (‘“’)前端必须用英文双引号;捕获异常并 friendly 返回 ConcurrentModificationExceptionjava.util.ConcurrentModificationException迭代时修改集合;先转成列表再遍历 NullPointerExceptionCannot invoke "JsonNode.asText()" ... get(...)判空或使用 ?.asText(default)

2025/6/7
articleCard.readMore

SpringBoot WebSocket 代理模式、客户端模式

前言 本文实现 上下游ws的代理功能、客户端发布功能 开发语言:Spring Boot + Kotlin 实现方式很多种,这里给出接口代码是思路,可以改 @ServerEndpoint 托管实现 代理模式 用户连接为上游,被代理地址为下游。 劫持控制修改上下游消息内容 对上游进行鉴权 时序设计 IWebSocketProxier时序图 时序代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 sequenceDiagram participant UpClient as 上游客户端 participant Proxier as IWebSocketProxier participant DownClient as 下游服务 participant Scheduler2 as 清理线程 Note over UpClient,Proxier: 1. 上游连接建立 UpClient->>Proxier: WebSocket 握手 activate Proxier Proxier-->>Proxier: afterConnectionEstablished(session) Proxier-->>Proxier: sessions[session.id] = WebSocketProxySession(...) Proxier-->>Proxier: onUpstreamOpen(proxy) deactivate Proxier Note over UpClient,Proxier: 2. 上游首条消息(授权) UpClient->>Proxier: TextMessage(首次消息) activate Proxier Proxier-->>Proxier: handleMessage(session, message) Proxier-->>Proxier: onUpstreamFirstMessage(proxy, message) alt 授权失败 Proxier-->>UpClient: sendMessage(授权失败通知) Proxier-->>Proxier: closeSession(session.id) else 授权成功 Proxier-->>Proxier: proxy.authorized = true Proxier-->>Proxier: onAuthSuccess(proxy) Proxier-->>Proxier: connectDownstream(session.id) Proxier-->>Proxier: downstreamContexts[session.id].pending.offer(clone(message)) end deactivate Proxier Note over UpClient,Proxier: 3. 上游后续消息 UpClient->>Proxier: TextMessage(后续消息) activate Proxier Proxier-->>Proxier: handleMessage alt !downConnected Proxier-->>Proxier: pending.offer(clone(message)) else 已连接下游 Proxier-->>DownClient: sendToDownstream(transformUpstream(message)) end deactivate Proxier Note over DownClient,Proxier: 4. 下游连接建立完成 DownClient->>Proxier: 握手完成 activate Proxier Proxier-->>Proxier: ctx.downConnected = true Proxier-->>Proxier: flush pending → sendToDownstream(...) deactivate Proxier Note over DownClient,Proxier: 5. 下游消息回传 DownClient->>Proxier: TextMessage(下游响应) activate Proxier Proxier-->>UpClient: proxy.session.sendMessage(transformDownstream(msg)) deactivate Proxier Note over Scheduler2,Proxier: 6. 会话超时自动清理 Scheduler2->>Proxier: cleanupExpired() activate Proxier Proxier-->>Proxier: closeSession(超时 session.id) deactivate Proxier Note over UpClient,Proxier: 7. 上游主动关闭 UpClient->>Proxier: closeConnection activate Proxier Proxier-->>Proxier: afterConnectionClosed(session, status) Proxier-->>Proxier: closeSession(session.id) deactivate Proxier 接口代码 IWebSocketProxier 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 import com.fasterxml.jackson.databind.ObjectMapper import org.slf4j.LoggerFactory import org.springframework.web.socket.* import org.springframework.web.socket.client.WebSocketClient import org.springframework.web.socket.handler.AbstractWebSocketHandler import java.util.concurrent.* import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger /** * 包装上游会话及其状态,用于管理授权和心跳 * * @param session WebSocket 上游会话 * @param authorized 是否已通过授权验证 * @param downConnected 下游连接是否已建立 * @param lastHeartbeat 最近心跳时间戳(毫秒) */ data class WebSocketProxySession( val session: WebSocketSession, var authorized: Boolean = false, var downConnected: Boolean = false, var lastHeartbeat: Long = System.currentTimeMillis() ) /** * 通用 WebSocket 代理抽象类 * * 负责管理上游和下游的连接生命周期、消息转发以及超时清理 * * 使用方式: * 1. 实现核心抽象方法: * - registerPath: 定义代理路由 * - onUpstreamFirstMessage: 处理上游首条消息并进行授权 * - downstreamUri: 获取下游 URI * - transformUpstream: 上游→下游 转换逻辑 * - transformDownstream: 下游→上游 转换逻辑 * 2. 可选覆盖钩子: * - onUpstreamOpen: 上游连接初始化 * - onAuthSuccess: 授权成功回调 * - onUpstreamFirstMessageIsNull: 授权失败处理 * - onSessionClosed: 会话关闭后处理 * * @param objectMapper 用于 JSON 序列化/反序列化 * @param client WebSocket 客户端,用于建立下游连接 * @author ThatCoder */ abstract class IWebSocketProxier( val objectMapper: ObjectMapper, private val client: WebSocketClient ) : AbstractWebSocketHandler() { /** 代理接入路径 */ abstract val registerPath: String /** 会话超时时间,默认 10 分钟 */ open val sessionTimeoutMillis: Long = 10 * 60 * 1000 private val logger = LoggerFactory.getLogger(this::class.java) private val sessions = ConcurrentHashMap<String, WebSocketProxySession>() private val downstreamContexts = ConcurrentHashMap<String, DownstreamContext>() private val scheduler = Executors.newSingleThreadScheduledExecutor( NamedThreadFactory("proxy-session-timeout-") ) init { // 定期清理超时会话 scheduler.scheduleAtFixedRate( { cleanupExpired() }, sessionTimeoutMillis, sessionTimeoutMillis, TimeUnit.MILLISECONDS ) } override fun afterConnectionEstablished(session: WebSocketSession) { logger.info("Upstream connected: ${session.id}") sessions[session.id] = WebSocketProxySession(session) onUpstreamOpen(sessions[session.id]!!) } override fun handleMessage(session: WebSocketSession, message: WebSocketMessage<*>) { val proxy = sessions[session.id] ?: return if (!proxy.authorized) { val ok = onUpstreamFirstMessage(proxy, message) if (!ok) { onUpstreamFirstMessageIsNull(proxy) closeSession(session.id) return } proxy.authorized = true onAuthSuccess(proxy) connectDownstream(session.id) downstreamContexts[session.id]?.pending?.offer(clone(message)) return } val ctx = downstreamContexts[session.id] ?: return if (!ctx.downConnected.get()) { ctx.pending.offer(clone(message)) } else { ctx.sendToDownstream(transformUpstream(message)) } } override fun afterConnectionClosed(session: WebSocketSession, status: CloseStatus) { logger.info("Upstream closed: ${session.id}") closeSession(session.id) } /** * 向所有上游会话发送心跳,维持长连接 */ fun sendHeartbeat() { val ping = PingMessage() sessions.values.forEach { try { it.session.sendMessage(ping) } catch (_: Exception) { // 忽略发送失败 } } } // ---------- 可覆盖钩子 ---------- /** 上游连接建立后回调 */ protected open fun onUpstreamOpen(proxy: WebSocketProxySession) = Unit /** * 上游首条消息处理并授权 * @return true 表示通过,false 则触发授权失败 */ protected abstract fun onUpstreamFirstMessage( proxy: WebSocketProxySession, message: WebSocketMessage<*> ): Boolean /** 授权失败发送给上游的消息 */ protected open fun onUpstreamFirstMessageIsNull(proxy: WebSocketProxySession) { val err = mapOf("finish" to true, "error" to "身份认证失败") proxy.session.sendMessage(TextMessage(objectMapper.writeValueAsString(err))) } /** 授权成功后回调 */ protected open fun onAuthSuccess(proxy: WebSocketProxySession) = Unit /** 根据上游会话获取下游 URI */ protected abstract fun downstreamUri(proxy: WebSocketProxySession): String /** 上游→下游 消息转换 */ protected abstract fun transformUpstream(message: WebSocketMessage<*>): WebSocketMessage<*> /** 下游→上游 消息转换 */ protected abstract fun transformDownstream(message: WebSocketMessage<*>): WebSocketMessage<*> /** 会话关闭后回调 */ protected open fun onSessionClosed(proxy: WebSocketProxySession) = Unit // ---------- 内部逻辑 ---------- /** * 建立下游连接,并将后续消息路由到 DownstreamContext */ private fun connectDownstream(sessionId: String) { val proxy = sessions[sessionId]!! val ctx = DownstreamContext(proxy) downstreamContexts[sessionId] = ctx client.execute(object : AbstractWebSocketHandler() { override fun afterConnectionEstablished(down: WebSocketSession) { logger.info("Downstream connected for: $sessionId") ctx.downConnected.set(true) ctx.downstream = down while (true) { val msg = ctx.pending.poll() ?: break ctx.sendToDownstream(transformUpstream(msg)) } } override fun handleMessage(down: WebSocketSession, msg: WebSocketMessage<*>) { proxy.session.sendMessage(transformDownstream(msg)) } override fun afterConnectionClosed(down: WebSocketSession, status: CloseStatus) { logger.warn("Downstream closed early: ${status.code}") closeSession(sessionId) } }, downstreamUri(proxy)) } /** 关闭并清理指定会话 */ private fun closeSession(sessionId: String) { sessions.remove(sessionId)?.also { onSessionClosed(it) } downstreamContexts.remove(sessionId)?.closeAll() } /** 清理超时会话 */ private fun cleanupExpired() { val now = System.currentTimeMillis() sessions.entries .filter { now - it.value.lastHeartbeat > sessionTimeoutMillis } .forEach { closeSession(it.key) } } /** 克隆消息以避免并发问题 */ private fun clone(msg: WebSocketMessage<*>): WebSocketMessage<*> = when (msg) { is TextMessage -> TextMessage(msg.payload) is BinaryMessage -> BinaryMessage(msg.payload.asReadOnlyBuffer()) else -> msg } /** * 管理下游消息发送及队列 */ private class DownstreamContext(proxy: WebSocketProxySession) { @Volatile var downstream: WebSocketSession? = null val downConnected = AtomicBoolean(false) val pending = ConcurrentLinkedQueue<WebSocketMessage<*>>() private val executor: ExecutorService = ThreadPoolExecutor( 4, 16, 60, TimeUnit.SECONDS, LinkedBlockingQueue(1000), NamedThreadFactory("proxy-send-${proxy.session.id}") ) /** 将消息异步发送到下游 */ fun sendToDownstream(msg: WebSocketMessage<*>) { executor.execute { try { downstream?.sendMessage(msg) } catch (e: Exception) { LoggerFactory.getLogger("DownstreamLogger").error("Send downstream failed", e) } } } /** 关闭下游并清理资源 */ fun closeAll() { try { downstream?.close() } catch (_: Exception) { } executor.shutdownNow() pending.clear() } } /** 为线程池生成可读性线程名 */ private class NamedThreadFactory(prefix: String) : ThreadFactory { private val cnt = AtomicInteger(1) private val name = "${prefix}-${cnt.getAndIncrement()}" override fun newThread(r: Runnable) = Thread(r, name) } } 实现示例 以代理 FunAsr 为例,统一上下游的消息类型,对上游进行身份权限认证 AsrProxier 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 import com.bidr.waterx.transpond.config.extension.fieldJust import com.bidr.waterx.transpond.config.extension.fieldRemove import com.bidr.waterx.transpond.config.extension.fieldRename import com.bidr.waterx.transpond.config.extension.putMap import com.bidr.waterx.transpond.config.extension.toObjectNode import com.bidr.waterx.transpond.config.extension.toTextMessage import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.node.ArrayNode import org.slf4j.LoggerFactory import org.springframework.stereotype.Component import org.springframework.web.socket.CloseStatus import org.springframework.web.socket.TextMessage import org.springframework.web.socket.WebSocketMessage import org.springframework.web.socket.client.WebSocketClient /** ASR 代理实现 */ @Component class AsrProxier( objectMapper: ObjectMapper, webSocketClient: WebSocketClient, private val akService: IAKService ) : IWebSocketProxier(objectMapper, webSocketClient) { private val paramProxy = mapOf( "id" to "wav_name", "finish" to "is_speaking", "answer" to "text" ) private val logger = LoggerFactory.getLogger(this::class.java) override val registerPath = "/ws/asr" // 鉴权服务 override fun onUpstreamFirstMessage(proxy: WebSocketProxySession, message: WebSocketMessage<*>): Boolean { val node = message.toObjectNode(objectMapper) ?: return false val ak = node.get("ak")?.asText() ?: return false return akService.check(ak) } override fun downstreamUri(proxy: WebSocketProxySession) = "ws://localhost:10095" // 处理上游消息适配成FUNASR接收类型 override fun transformUpstream(message: WebSocketMessage<*>) = when (message) { is TextMessage -> runCatching { val forward = message.toObjectNode(objectMapper)?.fieldRename(paramProxy) ?: return message forward.get("is_speaking")?.let { val finished = it.asBoolean(false) if (!finished) forward.putMap(mapOf( "language" to "zn", "itn" to false, "hotwords" to "{\"阿里巴巴\":20,\"hello world\":40}" )) forward.put("is_speaking", !finished) } forward.get("mode")?.let { if (listOf("mixed","online").contains(it.asText())) { val arr = objectMapper.createArrayNode().add(5).add(10).add(5) forward.set<ArrayNode>("chunk_size", arr) forward.put("chunk_interval", 10) if (it.asText() == "mixed") forward.put("mode", "2pass") } } forward.fieldRemove(listOf("ak")) logger.info("transformUpstream: $forward") forward.toTextMessage(objectMapper) }.getOrDefault(message) else -> message } // 处理下游消息适配成客户接收类型 override fun transformDownstream(message: WebSocketMessage<*>) = when (message) { is TextMessage -> { val forward = message.toObjectNode(objectMapper) ?.fieldRename(paramProxy.toMutableMap().plus("finish" to "is_final"), true) ?: return message forward.putMap(mapOf( "mode" to when (forward.get("mode")?.asText() ?: "2pass-offline") { "2pass-online" -> "online" "2pass-offline" -> "offline" else -> forward.get("mode").asText() }, "timestamp" to System.currentTimeMillis() )) forward.fieldJust(paramProxy.keys.plus("mode").toList()) logger.info("transformDownstream: $forward") forward.toTextMessage(objectMapper) } else -> message } override fun onUpstreamFirstMessageIsNull(proxy: WebSocketProxySession) { super.onUpstreamFirstMessageIsNull(proxy) proxy.session.close(CloseStatus.POLICY_VIOLATION) } } 客户端模式 客户端模式是自己为发布器,用户为上游,自己作为下游。 用户认证 会话对象维护 心跳维护 消息广播 消息过滤广播 单例模式 时序设计 IWebSocketPublisher时序图 时序代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 sequenceDiagram participant Client as 客户端 participant Publisher as IWebSocketPublisher participant Scheduler as 定时清理线程 Note over Client,Publisher: 1. 连接建立与初始化 Client->>Publisher: WebSocket 握手并建立连接 activate Publisher Publisher-->>Publisher: afterConnectionEstablished(session) Publisher-->>Publisher: onUpstreamOpen(session) deactivate Publisher Note over Client,Publisher: 2. 首次消息(身份验证) Client->>Publisher: TextMessage(首次消息) activate Publisher Publisher-->>Publisher: handleMessage(session, message) Publisher-->>Publisher: onUpstreamFirstMessage(session, message) alt 验证失败 Publisher-->>Client: onUpstreamFirstMessageIsNull → 发送错误提示 Publisher-->>Client: session.close(POLICY_VIOLATION) else 验证成功 Publisher-->>Publisher: sessions[session.id] = WebSocketSenderSession(...) Publisher-->>Publisher: onAuthSuccess(...) end deactivate Publisher Note over Client,Publisher: 3. 后续业务消息处理 Client->>Publisher: TextMessage(业务消息) 或 PingMessage(心跳) activate Publisher Publisher-->>Publisher: handleMessage alt 心跳 Publisher-->>Publisher: 更新 lastHeartbeat else 业务消息 Publisher-->>Publisher: onUpstreamMessage(...) end deactivate Publisher Note over Scheduler,Publisher: 4. 会话超时清理 Scheduler->>Publisher: cleanupExpired() activate Publisher Publisher-->>Publisher: 关闭过期 session → onSessionClosed deactivate Publisher Note over Publisher,Client: 5. 发布/广播/心跳 Publisher->>Client: publishAll/publishByFilter/publishSender Publisher-->>Publisher: transformPublish(...) Publisher-->>Client: sendMessage(转换后消息) Publisher->>Client: sendHeartbeat() → PingMessage() 接口代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 import com.fasterxml.jackson.databind.ObjectMapper import org.slf4j.LoggerFactory import org.springframework.web.socket.* import org.springframework.web.socket.handler.AbstractWebSocketHandler import java.util.concurrent.* import java.util.concurrent.atomic.AtomicInteger /** * 会话、用户信息的包装类 */ data class WebSocketSenderSession<T>( val session: WebSocketSession, val user: T, /** 心跳超时标志 */ val lastHeartbeat: Long = System.currentTimeMillis() ) /** * WebSocket 发布者抽象接口,用于构建支持用户认证、心跳维护、消息广播的通用 WebSocket 服务。 * * ### 使用方式 * * #### 必须实现 * > 继承本类并实现以下核心抽象方法 * - [registerPath]:注册的路径,WebSocket 接入入口 * - [onUpstreamFirstMessage]:处理上游客户端首次连接时的消息,一般用于身份验证,返回的用户信息将用于标识会话;若返回 null,连接将被关闭 * - [onUpstreamMessage]:处理客户端后续发送的消息 * * #### 可选重写 * - [onUpstreamOpen]:连接建立但未发送任何消息时的初始化回调 * - [onSessionClosed]:连接关闭后的回调处理 * - [onUpstreamFirstMessageIsNull]:首次消息认证失败时的回调,默认发送错误信息 * - [onAuthSuccess]:首次消息认证通过后的回调 * - [transformPublish]:发送消息前进行的消息变换 * * ### 会话管理 * - 会话信息以 [WebSocketSenderSession] 包装,包含 `session`、用户信息及心跳时间 * - 默认 10 分钟未活跃会话将被关闭,可通过 [sessionTimeoutMillis] 调整 * * ### 发布功能 * - [publishAll]:向所有连接发布消息 * - [publishByFilter]:根据过滤条件发布消息 * - [publishSender]:向单个连接发送消息 * - [sendHeartbeat]:向所有连接发送 Ping 消息,维持长连接 * * @param objectMapper Jackson 用于 JSON 序列化/反序列化 * @param T 用户类型,需由 [onUpstreamFirstMessage] 提供 * @author ThatCoder */ abstract class IWebSocketPublisher<T>( private val objectMapper: ObjectMapper ) : AbstractWebSocketHandler() { abstract val registerPath: String /** 会话超时毫秒数 默认十分钟 */ val sessionTimeoutMillis: Long = 10*60*1000 private val logger = LoggerFactory.getLogger(this::class.java) /** 所有会话管理器 */ val sessions = ConcurrentHashMap<String, WebSocketSenderSession<T>>() private val scheduler = Executors.newSingleThreadScheduledExecutor(NamedThreadFactory("session-timeout-")) init { // 定期清理超时会话 scheduler.scheduleAtFixedRate({ cleanupExpired() }, sessionTimeoutMillis, sessionTimeoutMillis, TimeUnit.MILLISECONDS) } override fun afterConnectionEstablished(session: WebSocketSession) { logger.info("Client connected: ${session.id}") onUpstreamOpen(session) } override fun handleMessage(session: WebSocketSession, message: WebSocketMessage<*>) { // 首次消息处理授权与注册 if (!sessions.containsKey(session.id)) { val user = onUpstreamFirstMessage(session, message) if (user == null) { onUpstreamFirstMessageIsNull(session) session.close(CloseStatus.POLICY_VIOLATION) return } sessions[session.id] = WebSocketSenderSession(session, user) onAuthSuccess(sessions[session.id]!!) logger.info("Session registered: ${session.id} -> $user") return } // 心跳更新或具体消息处理 onUpstreamMessage(sessions[session.id]!!, message) } override fun afterConnectionClosed(session: WebSocketSession, status: CloseStatus) { logger.info("Client closed: ${session.id} (${status.reason})") sessions.remove(session.id)?.let { onSessionClosed(it) } } /** * 全局发布消息 * @param message 消息 */ fun publishAll(message: WebSocketMessage<*>) { sessions.values.forEach { sender -> send(sender, message) } } /** * 按过滤器发布 * @param filter 过滤器 * @param message 消息 */ fun publishByFilter(filter: (WebSocketSenderSession<T>) -> Boolean, message: WebSocketMessage<*>) { sessions.values.filter(filter).forEach { send(it, message) } } /** * 发送消息给指定会话 * @param sender 会话 * @param message 消息 */ fun publishSender(sender: WebSocketSenderSession<T>, message: WebSocketMessage<*>) { send(sender, message) } /** 发送心跳 */ fun sendHeartbeat() { val ping = PingMessage() sessions.values.forEach { try { it.session.sendMessage(ping) } catch (_: Exception) {} } } // ========== 子类扩展点 =========== /** * 会话首次创建时调用 * @param session 会话 */ protected open fun onUpstreamOpen(session: WebSocketSession) = Unit /** * 会话消息 * @param sender 会话对象 * @param message 消息 */ protected abstract fun onUpstreamMessage(sender: WebSocketSenderSession<T>, message: WebSocketMessage<*>) /** * 会话关闭时调用 * @param sender 会话对象 */ protected open fun onSessionClosed(sender: WebSocketSenderSession<T>) = Unit /** * 会话首条消息 * * 通常在验证用户权限时调用 * @param session 会话 * @param message 消息 * @return 用户信息 如果返回null则触发 onUpstreamFirstMessageIsNull * @see onUpstreamFirstMessageIsNull */ protected abstract fun onUpstreamFirstMessage( session: WebSocketSession, message: WebSocketMessage<*> ): T? /** * 会话首条消息处理为空时调用 * @param session 会话 */ protected open fun onUpstreamFirstMessageIsNull(session: WebSocketSession) { val err = mapOf("error" to "身份认证失败") session.sendMessage(TextMessage(objectMapper.writeValueAsString(err))) } /** * 认证成功后执行 * @param sender 会话 */ protected open fun onAuthSuccess(sender: WebSocketSenderSession<T>) = Unit /** 清理超时会话 */ private fun cleanupExpired() { val now = System.currentTimeMillis() sessions.values.filter { now - it.lastHeartbeat > sessionTimeoutMillis } .forEach { try { it.session.close(CloseStatus.SESSION_NOT_RELIABLE) } catch (_: Exception) {} sessions.remove(it.session.id) logger.info("Session timeout removed: ${it.session.id}") } } private fun send(sender: WebSocketSenderSession<T>, message: WebSocketMessage<*>) { if (!sender.session.isOpen) return try { sender.session.sendMessage(transformPublish(message, sender)) } catch (e: Exception) { logger.error("Publish to ${sender.session.id} failed", e) } } protected open fun transformPublish( message: WebSocketMessage<*>, sender: WebSocketSenderSession<T> ): WebSocketMessage<*> = message private class NamedThreadFactory(prefix: String) : ThreadFactory { private val cnt = AtomicInteger(1) private val name = prefix + cnt.getAndIncrement() override fun newThread(r: Runnable): Thread { return Thread(r, name) } } } 实现示例 以实现聊天室为例,这个例子有对单对群发送演示 兼容单例模式,只使用 publishSender 方法即可, 相当于一对一服务 实现后可以多开几个网页测试 websocket测试网页 链接本地 ws://localhost:8080/ws/chat后可以发送一个body鉴权进群 {"ak": "123456", "message": "我是卢本伟", "name": "卢本伟"} 进群后续可以不发送 ak,已经有了sessionId对应的用户, 后面发送 {"message": "欢迎来到卢本伟广场"} 即可 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 import com.bidr.waterx.transpond.config.extension.toObjectNode import com.bidr.waterx.transpond.config.extension.toTextMessage import com.fasterxml.jackson.databind.ObjectMapper import org.springframework.stereotype.Component import org.springframework.web.socket.TextMessage import org.springframework.web.socket.WebSocketMessage import org.springframework.web.socket.WebSocketSession data class ChatUser(val userid: String, val name: String) /** * 聊天室发布者 */ @Component class ChatPublisher(private val objectMapper: ObjectMapper) : IWebSocketPublisher<ChatUser>(objectMapper) { override val registerPath = "/ws/chat" override fun onUpstreamFirstMessage(session: WebSocketSession, message: WebSocketMessage<*>): ChatUser? { val message = message.toObjectNode() ?: return null val ak = message.get("ak")?.asText() ?: return null val name = message.get("name")?.asText() ?: return null if (ak != "123456") return null // 创建用户 val user = ChatUser( session.id, name) // 给该用户发送欢迎信息 session.sendMessage(TextMessage("Hi, $name. Please chat friendly!")) // 群发用户入群提示 publishAll(TextMessage("$name've joined the chat room.")) return user } override fun onSessionClosed(sender: WebSocketSenderSession<ChatUser>) { // 群发用户离开提示 publishAll(TextMessage("${sender.user.name} has left the chat room.")) } override fun onUpstreamMessage(sender: WebSocketSenderSession<ChatUser>, message: WebSocketMessage<*>) { // 转发用户消息至群聊 publishAll(objectMapper.createObjectNode().apply { put("type", "chat") putPOJO("user", sender.user) putPOJO("message", message.toObjectNode()) }.toTextMessage()) } } 路由注册 两个接口都有 registerPath 所以我们可以让 Spring 收集 IWebSocketPublisher、IWebSocketProxier 的实现类,自动注册里面的路由实现 WebSocketConfig 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 package cn.uwant.auto.config import IWebSocketProxier import IWebSocketPublisher import jakarta.websocket.ContainerProvider import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.web.socket.client.WebSocketClient import org.springframework.web.socket.client.standard.StandardWebSocketClient import org.springframework.web.socket.config.annotation.EnableWebSocket import org.springframework.web.socket.config.annotation.WebSocketConfigurer import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry import org.springframework.context.annotation.Lazy import kotlin.collections.map /** * WebSocket配置 * @author ThatCoder */ @Configuration @EnableWebSocket class WebSocketConfig( @Lazy private val proxies: List<IWebSocketProxier>, @Lazy private val publishers: List<IWebSocketPublisher<*>> ) : WebSocketConfigurer { override fun registerWebSocketHandlers(registry: WebSocketHandlerRegistry) { proxies.map { registry.addHandler(it, it.registerPath).setAllowedOrigins("*") } publishers.map { registry.addHandler(it, it.registerPath).setAllowedOrigins("*") } } @Bean fun webSocketClient(): WebSocketClient { val container = ContainerProvider.getWebSocketContainer() return StandardWebSocketClient(container) } } 相关错误 见 BUG 专栏 Spring-WebSocket-Bughttps://blog.thatcoder.cn/bug/spring-websocket-bug/

2025/6/7
articleCard.readMore

Spring AOP 调用自身失效

错误情景 环境: Spring: 2.7.18 操作: 顶层方法分析所选数据源 切换数据源 调用对应查询 AOP代理失效导致多数据源切换失败 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 /** * 数据库元数据服务实现 */ @Service public class DataBaseMetaService implements IDataBaseMetaService { /** * 获取输入库表在数据库里的字段注释 */ @Override public List<DataBaseMateField> selectTableFieldsByScope(String dbName, String tableName) { List<DataBaseMateField> dataBaseMetaFields = ListUtil.empty(); switch (dbName) { case "xxx": dataBaseMetaFields = byMaster(tableName); break; case "xxxxxx": dataBaseMetaFields = byMonitorHm(tableName); if (CollectionUtils.isEmpty(dataBaseMetaFields)) dataBaseMetaFields = byMonitorWce(tableName); break; default: break; } return dataBaseMetaFields.stream().peek(field -> field.setFieldName(StringUtil.toCamelCase(field.getFieldName())) ).collect(Collectors.toList()); } @DS("a") public List<DataBaseMateField> byMaster(String tableName) {} @DS("b-a") public List<DataBaseMateField> byMonitorHm(String tableName) {} @DS("b-b") public List<DataBaseMateField> byMonitorWce(String tableName) {} } 错误诱因 Spring AOP 原理:@DS 依赖 AOP 代理,而 this.xxx() 直接调用自身方法,不会经过代理对象。 JDK 代理与 CGLIB 代理的区别:默认情况下,@Transactional 和 @DS 这种 AOP 机制都是基于代理的,需要从代理对象调用方法才能生效。 解决方案 方式 1:使用 @Lazy 注解注入自身(推荐) 1 2 3 @Lazy @Autowired private DataBaseMetaService self; 这样 self.byXXX() 实际是从代理对象调用,从而触发 AOP,确保 @DS 切换数据源生效。 方式 2:通过 AopContext 获取代理对象 1 2 DataBaseMetaService proxy = (DataBaseMetaService) AopContext.currentProxy(); proxy.byMaster(tableName); 需要开启 exposeProxy = true,在 application.yml 配置: 1 2 3 4 spring: aop: proxy-target-class: true expose-proxy: true 但这种方式代码侵入性较强,不如方式 1 优雅。 方式 3:将 byXXX() 方法抽取到另一个 @Service。这样 @DS 标注的方法始终在被代理对象上执行,避免 this 调用导致 AOP 失效。 结尾 我这里选择第一种解决方案,注入自身。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 /** * 数据库元数据服务实现 */ @Service public class DataBaseMetaService implements IDataBaseMetaService { // 延迟注入自身,确保 @DS 生效 @Lazy @Autowired private DataBaseMetaService self; private final DataBaseMetaMapper dataBaseMetaMapper; public DataBaseMetaService(DataBaseMetaMapper dataBaseMetaMapper) { this.dataBaseMetaMapper = dataBaseMetaMapper; } /** * 获取输入库表在数据库里的字段注释 */ @Override public List<DataBaseMateField> selectTableFieldsByScope(String dbName, String tableName) { List<DataBaseMateField> dataBaseMetaFields = ListUtil.empty(); switch (dbName) { case "xxx": dataBaseMetaFields = self.byMaster(tableName); break; case "xxxxxx": dataBaseMetaFields = self.byMonitorHm(tableName); if (CollectionUtils.isEmpty(dataBaseMetaFields)) dataBaseMetaFields = self.byMonitorWce(tableName); break; default: break; } return dataBaseMetaFields.stream().peek(field -> field.setFieldName(StringUtil.toCamelCase(field.getFieldName())) ).collect(Collectors.toList()); } @DS("a") public List<DataBaseMateField> byMaster(String tableName) {} @DS("b-a") public List<DataBaseMateField> byMonitorHm(String tableName) {} @DS("b-b") public List<DataBaseMateField> byMonitorWce(String tableName) {} }

2025/3/23
articleCard.readMore

window 端口占用但是查不到

错误情景 系统: window11 docker run in wsl 操作: idea run nacos 错误诱因 Window 默认预留的 TCP 动态端口范围与需要启动的服务端口冲突导致。 所以查不来的原因是端口确实未使用,但是保留。 解决方案 查看windows保留端口序列是否在冲突范围,默认应该是1024开始,步长为13977。所以我nacos的8848、9848都在里面 查看保留端口序列 1 netsh int ipv4 show dynamicport tcp 修改端口默认起始与步长,设置为自己不常用的区间。 start: 起始值 num: 长度修改保留端口序列 1 netsh int ipv4 set dynamicport tcp start=30000 num=13977 结尾 分享一个如果端口存在就kill端口的命令 e.g. killIf 8848 killIf 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 function killIf { param ( [Parameter(Mandatory = $true)] [int]$Port, [switch]$l ) try { # 获取 netstat 输出并过滤与端口相关的信息 $processInfo = netstat -ano | Select-String -Pattern ":\b${Port}\b" # 判空处理 if ($null -eq $processInfo -or $processInfo.Length -eq 0) { Write-Host "端口 $Port 未被占用。" -ForegroundColor Yellow Write-Host "提示:无法使用该端口,请检查是否有其他服务在使用,或尝试重启电脑。" -ForegroundColor Green return } # 提取唯一的进程ID $processIds = $processInfo | ForEach-Object { ($_ -split '\s+')[-1] } | Select-Object -Unique foreach ($ProcId in $processIds) { try { # 确认 PID 是否为有效的数字 if ($ProcId -match '^\d+$') { if ($l) { Write-Host "端口 $Port 存在进程号: ${ProcId}" -ForegroundColor Yellow } # 尝试终止进程 taskkill /PID $ProcId /F Write-Host "已终止进程 ${ProcId}, 释放端口 $Port 完毕。" -ForegroundColor Green } else { Write-Host "不正确的进程号: ${ProcId}" -ForegroundColor Red } } catch { Write-Host "终止进程 ${ProcId} 失败: $($_.Exception.Message)" -ForegroundColor Red } } } catch { Write-Host "发生错误: $($_.Exception.Message)" -ForegroundColor Red } } 它是一个​PowerShell 脚本​(扩展名为 .ps1),放到 $PROFILE 这个变量下面就行,直接在命令行输入 $PROFILE 有地址。

2025/3/7
articleCard.readMore

Coze同插件不同工具之间代码复用

问题描述 用官方在线IDE的Node环境开发Coze插件的工具时,如果import复用其它模块定义好的函数、类、类型等,会出现类似如下报错: 1 2 3 Error: Cannot find module 'xxx' ESLint couldn't find an eslint.config.(js|mjs|cjs) file. 问题已解决,急的话直接点击跳转到 最终方案 部分。 最终效果 分析原因 毫无疑问,我们编写的IDE文件是一个ts文件,而Coze插件运行时是Node环境,Node环境运行时模块加载机制不能直接加载ts文件,因此需要先编译成js文件才能运行。 而编译过程中,如果遇到import语句,就会去查找对应的模块文件,但由于Node环境无法直接运行ts文件,因此会报错。 尝试解决 我大致思考尝试了如下方案 方案一:修改配置,但是我们无法修改IDE的配置。 方案二:用额外的包去支持ts文件,比如ts-node、ts-node-dev等。但是我们不能控制命令行。 方案三:用 const {xxx} = require('../common/common') 这种方式导入模块。但是这样会导致IDE没有注释提示且无法提示具体属性(导入的类型是一个any),无法自动补全。 经过一番挣扎,方案三是最佳可行方案,起码能解决基本的模块导入问题。最后我们要解决的是IDE的注释提示和自动补全问题,也就是编译时类型推断问题。 最终方案 虽然丑陋,但是好用。 举例定义一个通用请求工具 通用工具 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 import { Args, Logger } from '@/runtime'; import axios, { AxiosInstance } from 'axios'; // 省略handle函数 // 基础响应类型 export type HttpRes<T> = { code: number message: string result: boolean data: T[] } // 定义通用请求工具 export abstract class BaseApi<T> { private logger: Logger; info(...args: any[]) { this.logger.info(...args); } host: string = 'https://example.com'; constructor(baseUrl: string, logger: Logger){ this.api = axios.create({ baseURL: this.host + baseUrl, headers: { "Content-Type": "application/json" } }); this.logger = logger; } async get(url: string, params: Object): Promise<HttpRes<T> | null> { const res = (await this.api.get(url, {params}))?.data this.info(url, res, params, {count: res?.data?.length || null}) return res } // async post() // ... } export type TOrder = {} export class OrderApi extends BaseApi<TOrder> { constructor(logger: Logger) { super("/order", logger); } async getOrders(userId: string): Promise<HttpRes<TOrder> | null> { return await this.get('/list', {userId}) } } // ... 省略其它API 在其它工具代码中导入,并使用 typeof import() 去获取类型信息,这样IDE能自动补全提示。 需求工具导入 1 2 3 4 5 6 7 8 9 10 11 import { Args, Logger } from '@/runtime'; const { OrderApi }: { OrderApi: typeof import("../common/common").OrderApi } = module.require("../common/common"); export async function handler({ input, logger }: Args<Input>): Promise<Output> { const userId = input.userId const api = new OrderApi(logger) const orders = await api.get('/order', {userId}) return { data: orders }; }; 总结 typeof import 是 TypeScript 提供的静态类型推断工具,它在 编译阶段 就能捕捉模块的导出结构,而无需等到运行时去加载实际模块。 这一特性让我们能够应付 Coze 插件运行时环境中无法使用 import 的限制,在编译时获取类型信息,而不必依赖模块是否能被实际解析。 至于为何 require() 能支持动态导入,是因为做了一些拦截并转译工作,使得 require() 运行能支持动态导入。 总之,在Coze的IDE的Node环境中,使用运行时依赖得靠 require(),而在编译时得到依赖类型得靠 typeof import() 去做静态类型检查。 闲聊 好久没更新博客,都忘了怎么发布文章,有闲暇时候还是多写写保持思考与输出。

2024/12/21
articleCard.readMore

Pachelbel's Greatest Hit: The Ultimate Canon - 纪念帕海贝尔:终极卡农

资源介绍 为了庆祝帕海贝尔350周年诞辰(1653/9/1),BMG唱片公司特别搜罗分佈在全球旗下的各领域知名乐手、乐团,以15首不同编曲、配器与演奏风格的卡农演出版本,来纪念这位音乐家。 专辑囊括了许多顶尖音乐家和极具时代意义的指标性演出版本,诸如在20世纪(1940年)第一个将“卡农”这首巴罗克杰作以大眾流行手法演出、带动古典音乐普及化功不可没的费德勒与他的小交响乐团;知名的法国百雅室内乐团的演出则是早期身历声录音时代(1970)广播电臺最热门的播放版本;长笛名家詹姆斯.高威自编自演的招牌长笛版,是一份保留了巴罗克原味的优雅版本;无与伦比的双钢琴演出,则是市调票选的人气首选版本;日本作曲家兼电子音乐大师富田勋的改编版,是最教人惊豔的现代版电子合成卡农;加拿大铜管乐团自编自演的管乐演出,展示出金属色泽的堂皇卡农;葛莱美奖女歌手克丽欧·莲恩的填词演唱版“何如、何处、何时”,成为风景独特的福音版;白金美声无伴奏重唱乐团展现了纯净圣洁的巴罗克正统;独一无二的“尺八与箏乐五重奏”版本巧妙地以东方古乐器呈现西方古乐;此外,被乐坛寄予厚望的英国古典吉他新秀克裏夫·卡洛首次面世的古典超技吉他独奏版;西班牙的古典吉他世家罗梅洛的吉他与音色合成器配合的协奏版,赋予出人意表的现代风貌;由伦敦市政厅弦乐团演出的终结乐章则忠实地以正统的巴罗克编制演出,让听眾亲炙原汁原味的正宗卡农。全专辑计有十轨全球首度重新编录版本,绝对能够满足无数卡农迷的重度搜藏欲。 曲目风格 管弦版永远的神,长笛的也很绝 Canon in D 室内管弦版卡农 Canon in D 长笛协奏版卡农 Canon (Over a Basso Ostinato) 钢琴二重奏版卡农 Canon of the Three Stars 现代版电子合成卡农 How, Where, When? (Canon in D) Earth Angel - Williams 尘世天使 Canon 铜管版卡农 Canon in F F大调古乐器版卡农 Variations on Pachelbel’s Canon in D 弦乐四重奏变奏版卡农 Canon 超技吉他独奏版卡农 Canon 美声无伴奏版卡农 Sweet Home - Sakakibara, Dai Canon 吉他&音色合成器版卡农 Canon in D 跨界乐团版卡农 Canon & Gigue in D 卡农与吉格舞曲 资源分享 试听地址 网易云音乐 下载地址 终极卡农(DSD)https://alist.thatcdn.cn/Tianyi/Hi-Res/%E7%BA%AF%E9%9F%B3/%E5%8D%A1%E5%86%9C TIPS DSD类资源一般都比较大,对播放与监听设备可能要求较高。

2024/8/13
articleCard.readMore

圣诞快乐,劳伦斯先生 Merry Christmas Mr. Lawrence

资源介绍 《战场上的快乐圣诞 Merry Christmas Mr. Lawrence》 《战场上的快乐圣诞 Merry Christmas Mr. Lawrence》 电影就不多评价,总之主演里面有北野武和坂本龙一,两位都是很出色有趣。虽然坂本龙一是搞音乐,北野武后来成为大导演了。 想起朋友推荐过一本书《北野武的小酒馆》,里面也提过坂本龙一的合作。朋友说我语言和性格像北野武。 《北野武的小酒馆》 书中桥段 配乐的原声经典曲《Merry Christmas Mr. Lawrence》与后来坂本龙一演奏的有些不一样,后流行演奏版下面也会单独给出,出自《夜(nacht)》的收录曲。 资源分享 电影原声专辑 试听地址 网易云音乐 下载地址 圣诞快乐,劳伦斯先生 OST原声(DSD)https://alist.thatcdn.cn/Tianyi/Hi-Res/%E7%BA%AF%E9%9F%B3/%E5%9D%82%E6%9C%AC%E9%BE%99%E4%B8%80/Merry%20Christmas%20Mr.%20Lawrence 流行单曲 试听地址 网易云音乐 下载地址 圣诞快乐,劳伦斯先生《夜》(DSD)https://alist.thatcdn.cn/Tianyi/Hi-Res/%E7%BA%AF%E9%9F%B3/%E5%9D%82%E6%9C%AC%E9%BE%99%E4%B8%80/%E5%A4%9C%20(nacht)/%E5%9C%A3%E8%AF%9E%E5%BF%AB%E4%B9%90,%20%E5%8A%B3%E4%BC%A6%E6%96%AF%E5%85%88%E7%94%9F%20(Merry%20Christmas%20Mr.Lawrence).dsf TIPS DSD类资源一般都比较大,对播放与监听设备可能要求较高。

2024/8/12
articleCard.readMore

设计模式系列——观察者模式

模式 一种订阅机制, 在可观察对象事件发生时通知多个 “观察” 该对象的其他对象。中文以订阅者(观察者)和订阅对象(可观察对象)更容易理解,而发布者理解为统一的通知部门。 啊〰老师老师,有人就要问了,为什么不用Kafka?Redis?RabbitMQ? 没有为什么,Kafka、Redis、RabbitMQ都是消息队列,但观察者模式是一种更加通用的模式,可以用于非使命必达的场景。 发布者 (Publisher): 定义:当可观察对象发生变更,筛选对应的订阅者并发布他们关注的内容 订阅者 (Subscriber): 定义:除了有update方法,订阅者还需要实现逻辑来处理发布者的通知参数 场景 这个模式的生活场景巨多,就比如 一蓑烟雨 的博客就有文章订阅 哈哈哈 邮箱订阅:给感兴趣的人推送更新,当然现在不感兴趣也会被迫收到。 期刊订阅:小学订阅的小学生之友,还有英语老师让大家(可自愿)订阅的英语报。 菜市场:和老板娘说有漂亮的五花肉记得打电话给我。就是她有时候会忘记。 群聊通知:排除掉开启了免打扰的成员,剩下的都是订阅者。 案例 简单点 一个商品降价订阅通知,商品为小米SU7,为了能在线分享用 TypeScript 写案例分享。 以下代码点击 codesandbox 按钮即可运行。 观察者接口 定义了基本的观察者接口,有观察者的信息和可观察对象的变更回调方法update() 观察者接口 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 // Observer.ts 观察者接口 export interface Observer {   // 可观察对象变更回调   update(product: string, price: number): void;   userUUID: string;   email: string;   subscriptionType: SubscriptionType;   discountThreshold?: number; // 仅对 DISCOUNT_TO 类型有效 } // 订阅类型枚举 export class SubscriptionType {   private constructor(public readonly model: string) {}   static readonly IN_STOCK = new SubscriptionType("IN_STOCK");   static readonly DISCOUNT = new SubscriptionType("DISCOUNT");   static readonly DISCOUNT_TO = new SubscriptionType("DISCOUNT_TO");   getDescription(): string {     switch (this.model) {       case "IN_STOCK":         return "来货通知";       case "DISCOUNT":         return "降价通知";       case "DISCOUNT_TO":         return "降价到预期通知";       default:         return "未知订阅";     }   } } 观察者实现 实现了观察者,增加了发送邮箱这个实际的通知方法,在update()实现通知调用 观察者接口 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 // UserObserver.ts 实现具体的观察者,处理不同类型的通知 import {logger} from "../util/Logger" import { Observer, SubscriptionType } from "./Observer"; export class UserObserver implements Observer {   constructor(     public userUUID: string,     public email: string,     public subscriptionType: SubscriptionType,     public discountThreshold?: number // 仅对 DISCOUNT_TO 类型有效   ) {}   update(product: string, price: number): void {     switch (this.subscriptionType) {       case SubscriptionType.IN_STOCK:         this.sendEmailNotification(`${product} 来货了!`);         break;       case SubscriptionType.DISCOUNT:         this.sendEmailNotification(`${product} 现在已经降价至 $${price}!`);         break;       case SubscriptionType.DISCOUNT_TO:         this.sendEmailNotification(           `${product} 现在已经降价至 $${price}, 满足您期待的降价 $${             this.discountThreshold ?? 0           }% !`         );         break;     }   }   private sendEmailNotification(message: string): void {     logger.info(`发送邮件 ${this.email}: ${message}`);   } } 可观察者接口 定义了基本的可观察者接口,主要有订阅、取消订阅、通知三要素。 可观察者接口 1 2 3 4 5 6 7 8 9 10 11 12 13 // Observable.ts 定义一个可观察对象接口,包括订阅、取消订阅和通知方法 import { Observer } from "../Observer"; export interface Observable {   // 订阅   subscribe(observer: Observer): void;   // 取消订阅   unsubscribe(observer: Observer): void;   // 通知   notifyObservers(): void; } 可观察者实现 实现了一个商品观察对象 可观察者实现 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 // ProductObservable.ts  实现具体的可观察对象(商品通知器) import { Observable } from "./Observable"; import { Observer, SubscriptionType } from "../Observer"; import { logger } from "../../util/Logger"; export class ProductObservable implements Observable {   private publishers: Observer[] = [];   private currentPrice: number = 0.0;   private originalPrice: number = 100.0; // 原始价格,用于比较   constructor(private product: string) {     logger.info(       `创建可观察对象(商品:${product}),价格 $${this.originalPrice}`     );   }   subscribe(publisher: Observer): void {     this.publishers.push(publisher);     logger.info(       `用户UUID: ${publisher.userUUID} ,成功订阅商品 ${         this.product       } ,订阅类型 ${publisher.subscriptionType.getDescription()}.`     );   }   unsubscribe(publisher: Observer): void {     this.publishers = this.publishers.filter(       (obs) => obs.userUUID !== publisher.userUUID     );     logger.info(       `用户UUID: ${publisher.userUUID} ,取消订阅商品 ${this.product} `     );   }   notifyObservers(): void {     for (const publisher of this.publishers) {       switch (publisher.subscriptionType) {         case SubscriptionType.IN_STOCK:           publisher.update(this.product, this.currentPrice);           break;         case SubscriptionType.DISCOUNT:           if (this.currentPrice < this.originalPrice) {             publisher.update(this.product, this.currentPrice);           }           break;         case SubscriptionType.DISCOUNT_TO:           if (this.currentPrice <= (publisher.discountThreshold ?? 0)) {             publisher.update(this.product, this.currentPrice);           }           break;       }     }   }   productRestocked(): void {     logger.info(`商品 ${this.product} 采购成功`);     this.notifyObservers();   }   productDiscounted(newPrice: number): void {     this.currentPrice = newPrice;     if (newPrice === this.originalPrice) {       logger.info(`商品 ${this.product} 恢复原价`);     } else {       logger.info(`商品 ${this.product} 降价至: $${this.currentPrice}`);     }     this.notifyObservers();   } } 测试效果 创建 小米SU7 这个可观察对象 三个用户关注了 小米SU7,关注类型不一样 在 小米SU7 库存和价格变动时候可以观测到对应的通知变化 测试 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 // main.ts import { ProductObservable } from "./observable/ProductObservable"; import { UserObserver } from "./UserObserver"; import { SubscriptionType } from "./Observer"; import { logger } from "../util/Logger"; export const TestObserver = () => {   // 创建可观察对象(商品通知器)   const su7Notifier = new ProductObservable("小米SU7");   // 创建观察者(用户)   const user1 = new UserObserver(     "UUID-1111",     "user1@thatcoder.cn",     SubscriptionType.IN_STOCK   );   const user2 = new UserObserver(     "UUID-2222",     "user2@thatcoder.cn",     SubscriptionType.DISCOUNT   );   const user3 = new UserObserver(     "UUID-3333",     "user3@thatcoder.cn",     SubscriptionType.DISCOUNT_TO,     50   );   // 用户1订阅iPhone 15有货通知   su7Notifier.subscribe(user1);   // 用户2订阅iPhone 15降价通知   su7Notifier.subscribe(user2);   // 用户3订阅iPhone 15降价到50%通知   su7Notifier.subscribe(user3);   // 商品到货,通知相关用户   su7Notifier.productRestocked();   // 商品降价,通知相关用户   su7Notifier.productDiscounted(60.0);   // 商品恢复原价   su7Notifier.productDiscounted(100.0);   // 商品降价到50%,通知相关用户   su7Notifier.productDiscounted(45.0);   // 用户1取消iPhone 15的订阅   su7Notifier.unsubscribe(user1);   // 商品到货,通知剩余的用户   su7Notifier.productRestocked(); }; 测试结果 和预想一致,可观察对象只需要关注自己的变动就可以了,用户考虑的就多了(还要点击订阅)。 降价到60,所以用户3不被通知 用户1取消订阅,所以来货了也不被通知 当然这是最简单的示例 运行结果 Spring监听机制 Spring有EventListener类似去定义一个事件的处理逻辑,相当于在里面写了订阅者的通知方法。ApplicationEventPublisher会去发布定义的事件,相当于可观察者的对象发生了变动。不同的是我们只关心发布和处理逻辑即可,中间的调用交给了Listener。 生命周期事件 在包 org.springframework.context.event 下面有很多与 ApplicationContext 生命周期相关的事件,这些事件都继承自 ApplicationContextEvent,包括 ContextRefreshedEvent, ContextStartedEvent, ContextStoppedEvent, ContextClosedEvent。 到了对应的生命周期会调用订阅。 启动和刷新 1 2 3 4 5 6 7 8 9 10 import org.springframework.context.ApplicationListener import org.springframework.context.event.ContextRefreshedEvent import org.springframework.stereotype.Component @Component class StartupListener : ApplicationListener<ContextRefreshedEvent> { override fun onApplicationEvent(event: ContextRefreshedEvent) { println("应用刷新成功!") } } 事务监听 @TransactionalEventListener 举例一个下单成功后的发布事务 事件定义 1 data class OrderPlacedEvent(val orderId: String, val userEmail: String) 事件处理 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 import org.springframework.context.event.TransactionalEventListener import org.springframework.stereotype.Component @Component class OrderPlacedEventListener { @TransactionalEventListener @Async fun handleOrderPlacedEvent(event: OrderPlacedEvent) { // 发送订单确认邮件 val orderId = event.orderId val userEmail = event.userEmail println("发送 $orderId 信息到用户邮箱 $userEmail") // 实际发送邮件的逻辑... } } 事件触发 1 2 3 4 5 6 7 8 9 10 11 12 13 14 import org.springframework.context.ApplicationEventPublisher import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @Service class OrderService(private val eventPublisher: ApplicationEventPublisher) { @Transactional fun save(order: Order) { // 处理下单逻辑... // 发布事件 eventPublisher.publishEvent(OrderPlacedEvent(orderId, userEmail)) } } 总结 优点 代码解耦:观察者和订阅者的逻辑分开,订阅者只引用了抽象的发布者接口,每个可观察者只需要关注自己的实现。 抽象耦合:如上代码解耦后逻辑上依然保持着抽象的耦合,订阅者只需要注册订阅即可 缺点 隐式依赖:抽象耦合就代表着事件通知机制是隐式的,系统的行为可能变得难以预测和理解。及时补充文档,不然就慢慢DEBUG。 瞬时峰值:某个可观察对象有大量订阅时,触发update带来的巨额性能开销可能会导致性能瓶颈,甚至系统阻塞。注意异步和削峰。 并发问题:多线程中,事件的发布和订阅者的变动可能带来并发问题。需要复杂的同步机制来确保线程安全,比如ConcurrentModificationException。除了线程安全的集合可能还需要考虑显式锁、读写锁或原子操作。 IDEA的监听耳机

2024/6/10
articleCard.readMore

设计模式系列——责任链模式

开个坑补齐设计模式系列笔记, 顺带回顾Spring源码中的设计模式运用… 模式 责任链模式(Chain of Responsibility Pattern)是一种行为设计模式,它允许多个对象有机会处理同一个请求,从而避免请求的发送者与多个接收者之间的耦合, 通常用于请求有多个阶段。该模式通过将这些对象连成一条链,并沿着链传递请求,直到有一个对象处理请求为止。如果链的末端没有对象处理请求,整个请求将被丢弃或默认处理。 未加粗的是责任链复杂程度上去之后可选的角色 抽象处理者(Handler): 定义:一个接口或抽象类,通常包含一个方法来处理请求以及一个方法来设置下一个处理者。它规定了所有具体处理者都必须实现的基本操作,如处理请求或将请求传递给链中的下一个处理者。 角色:接口或抽象类 作为处理链的核心定义,保证每个处理者都具有处理请求的能力。 基础处理者 (Base Handler): 可选 定义:一个接口或部分实现类,它实现了抽象处理者的部分功能或是抽象处理者的拓展。 角色:复用和扩展 提供公共的逻辑实现,减少子类的重复代码,增强代码的可复用性。 具体处理者(ConcreteHandlers): 定义:每个实现类,实现了处理某种请求的逻辑。当它无法处理请求时,会将请求传递给链中的下一个处理者。 角色:链的节点实现 实现自己的处理逻辑来参与责任链的请求处理。 链的管理者(Chain Manager):可选 定义:通常是一个Chain类,实现创建和管理处理者的顺序、组装责任链,并维护链的整体状态。 角色:构建和维护责任链 确保处理者按照正确的顺序处理请求,同时可以动态地添加、移除或调整链中的处理者。 链的构造器(Chain Creator):可选 定义:通常是一个工厂类或构造器,负责初始化责任链的结构并返回链的起始处理者。它可以根据具体的业务需求,创建不同类型的责任链。 角色:生成责任链实例 确保处理者按照正确的顺序处理请求,同时可以动态地添加、移除或调整链中的处理者。 客户类(Client): 定义:客户类是发起请求的对象,它通常会自行或调用链的构造器创建并设置责任链,然后向责任链的第一个处理者提交请求。 角色:责任链的创建者 请求的发起者 责任链模式结构图 场景 公司报销流程就像一条责任链,每个环节都根据条件决定是否继续处理: 填写申请:你提交的报销单是链上的第一个环节,检查所有必要的字段和金额,确保格式正确。如果不符合条件,它会被退回。 审批环节:提交后的申请进入审批环节(链路)。每一级上司根据公司政策比如费用上限或是否有票据判断是否继续处理。如果符合条件或该审批者无权审批,申请会被传递到下一个环节。 财务审核:通过所有审批后,财务部门再进行一次核查,确保链路都是true且符合财务规定。如果审核通过,申请继续流向资金发放环节 (实际的处理方法)。 资金发放:经过所有环节的条件检查和批准,资金会发放到你的账户中。 每个环节都有自己的条件判断,每个节点只处理自己能处理的部分,不符合条件的节点将被跳过,确保整个流程高效顺畅。 案例 简单点 先来个简单点的,虚拟一个简单的过滤器链实现和实际的使用,用来处理HTTP请求。 抽象处理者 接口定义 1 2 3 4 5 6 7 8 interface Handler : Comparable<Handler> { var index: Int // 责任链顺序 fun setNext(handler: Handler): Handler // 下一个 fun handleRequest(request: Request) // 处理方法,处理同一种入参 override fun compareTo(other: Handler): Int = this.index - other.index } 基础处理者 基础实现 1 2 3 4 5 6 7 8 9 10 11 12 13 // 基础处理者,提供处理链传递逻辑,并实现index abstract class BaseHandler(override var index: Int) : Handler { private var nextHandler: Handler? = null override fun setNext(handler: Handler): Handler { nextHandler = handler return handler } override fun handleRequest(request: Request) { nextHandler?.handleRequest(request) } } 具体处理者 具体实现 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 // 具体处理者1:处理认证逻辑 class AuthenticationHandler(index: Int) : BaseHandler(index) { override fun handleRequest(request: Request) { if ("ROLE_ADMIN" == request?.role) { super.handleRequest(request) } else { response.WriteError("认证失败") } } } // 具体处理者2:处理日志记录逻辑 class LoggingHandler(index: Int) : BaseHandler(index) { override fun handleRequest(request: Request) { Logger.info("登录:${request.username}") super.handleRequest(request) } } 链管理者 链管理者 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 // 链的管理者:负责组装和管理责任链 class ChainManager { private val handlers: MutableList<Handler> = mutableListOf() // 添加处理器到链中,指定顺序 fun addHandler(handler: Handler): ChainManager { handlers.add(handler) return this } // 从链中移除处理器 fun removeHandler(handler: Handler): ChainManager { handlers.remove(handler) return this } // 构建责任链 fun buildChain(): Handler? { if (handlers.isEmpty()) { throw IllegalArgumentException("空链") } // 根据index字段排序 handlers.sort() // 链接处理器 for (i in 0 until handlers.size - 1) { handlers[i].setNext(handlers[i + 1]) } return handlers.firstOrNull() } } 链构建者 链构建者 1 2 3 4 5 6 7 8 9 // 链的创建者:工厂类,用于创建责任链 object ChainCreator { fun createDefaultChain(): Handler? { val manager = ChainManager() manager.addHandler(LoggingHandler(2)) // 指定顺序 .addHandler(AuthenticationHandler(1)) return manager.buildChain() } } 使用 这里假设将我们虚拟的责任链加入到请求拦截器,真实这里应该是基于HandlerInterceptor实现的处理者。 run 1 2 3 4 5 6 7 8 9 10 11 @Configuration class WebConfig : WebMvcConfigurer { override fun addInterceptors(registry: InterceptorRegistry) { // 创建责任链 val chain = ChainCreator.createDefaultChain() // 注册责任链的第一个处理者 registry.addInterceptor(chain) } } Spring Web 在 Spring Web 框架中,责任链模式被广泛应用于请求处理的各个阶段,尤其是在处理请求拦截和映射时。 看 Spring Web 案例代码之前,现梳理一遍请求到链路末端处理到返回的逻辑,比如启动并访问 OrderController 的 @GetMapping("/order/list") 的 public ResponseEntity<ResponseRow> list() 方法。 Spring Boot 启动时,SpringApplication.run() 方法会被调用。这一方法负责引导和启动 Spring 应用程序上下文,加载所有的配置类和 Bean。 理所当然 DispatcherServlet.java 作为核心组件会被通过自动配置机制自动注册并初始化,当作 Spring MVC 的前端控制器,用于接收和处理所有的 HTTP 请求。 DispatcherServlet 初始化时会调用 initHandlerMappings() 方法来加载所有的HandlerMapping 实现类储存到私有属性 private List<HandlerMapping> handlerMappings,并且等待处理即将到来的请求。 HandlerMapping 类负责将请求映射到适当的处理器(例如 @Controller 中的方法)。 用户发出 /order/list 请求时,DispatcherServlet 作为前端控制器接收 HTTP 请求,在 doDispatch() 方法中遍历 handlerMappings 列表并调用每个 HandlerMapping 实现类的 getHandler() 方法,尝试找到合适的 HandlerExecutionChain 来处理这个请求。 我们举例是 @GetMapping("/order/list") 所以会用RequestMappingHandlerMapping识别出 OrderController 中相应的处理方法,并返回一个 HandlerExecutionChain。这个 HandlerExecutionChain 包含了处理该请求的控制器方法(如 OrderController.list())以及相关的拦截器链。 当然拦截器(多个 HandlerInterceptor 以及可能是异步的 AsyncHandlerInterceptor 和 WebRequestHandlerInterceptorAdapter )可能会执行一些通用逻辑,如权限验证、日志记录等,都是按既定顺序执行。如果所有的拦截器都允许请求通过,将调用实际的处理器方法(如 OrderController.list())。 调用实际的处理器方法后还会继续调用拦截器的 postHandle() 方法,在生成响应之前对结果进一步处理。最后,无论请求是否成功,afterCompletion() 方法都会被调用,此为清理操作。 关系图 DispatcherServlet.java RequestMappingHandlerMapping.java 抽象处理者、基础处理者、具体处理者 HandlerInterceptor 抽象处理者 网络请求处理的不同阶段拦截器 public interface HandlerInterceptor { default boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { return true; }//调用"Controller"方法之前 default void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable ModelAndView modelAndView) throws Exception { }//调用"Controller"方法之后渲染"ModelAndView"之前 default void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable Exception ex) throws Exception { }//渲染"ModelAndView"之后 } AsyncHandlerInterceptor 基础处理者 继承了 HandlerInterceptor 接口,并添加了处理异步请求的方法。 public interface AsyncHandlerInterceptor extends HandlerInterceptor { default void afterConcurrentHandlingStarted(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { } } WebRequestHandlerInterceptorAdapter 具体处理者 实现了 AsyncHandlerInterceptor 接口。并将 WebRequestInterceptor 转换为 HandlerInterceptor。它将 WebRequestInterceptor 的处理逻辑适配为 HTTP 请求处理逻辑,并处理异步请求的逻辑。 public class WebRequestHandlerInterceptorAdapter implements AsyncHandlerInterceptor { private final WebRequestInterceptor requestInterceptor; public WebRequestHandlerInterceptorAdapter(WebRequestInterceptor requestInterceptor) { Assert.notNull(requestInterceptor, "WebRequestInterceptor must not be null"); this.requestInterceptor = requestInterceptor; } public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { this.requestInterceptor.preHandle(new DispatcherServletWebRequest(request, response)); return true; } public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable ModelAndView modelAndView) throws Exception { this.requestInterceptor.postHandle(new DispatcherServletWebRequest(request, response), modelAndView != null && !modelAndView.wasCleared() ? modelAndView.getModelMap() : null); } public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable Exception ex) throws Exception { this.requestInterceptor.afterCompletion(new DispatcherServletWebRequest(request, response), ex); } public void afterConcurrentHandlingStarted(HttpServletRequest request, HttpServletResponse response, Object handler) { WebRequestInterceptor var5 = this.requestInterceptor; if (var5 instanceof AsyncWebRequestInterceptor asyncInterceptor) { DispatcherServletWebRequest webRequest = new DispatcherServletWebRequest(request, response); asyncInterceptor.afterConcurrentHandlingStarted(webRequest); } } } DispatcherServlet 客户端 该调度程序负责配置和执行拦截器链。Spring MVC 的前端控制器,用于接收和处理所有的 HTTP 请求。 这条链路客户端到处理器中间其实还有两个部分,稍后就有介绍 HandlerMapping:用于查找与请求匹配的处理器(Controller),并返回HandlerExecutionChain。 HandlerExecutionChain:包含处理器和一系列拦截器(处理器)(HandlerInterceptor)。它负责在请求处理过程中依次调用链中的拦截器。 public class DispatcherServlet extends FrameworkServlet { ... protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception { HandlerExecutionChain mappedHandler = null; ... // 获取处理链 mappedHandler = this.getHandler(processedRequest); ... } ... @Nullable protected HandlerExecutionChain getHandler(HttpServletRequest request) throws Exception { if (this.handlerMappings != null) { Iterator var2 = this.handlerMappings.iterator(); while(var2.hasNext()) { HandlerMapping mapping = (HandlerMapping)var2.next(); HandlerExecutionChain handler = mapping.getHandler(request); if (handler != null) { return handler; } } } return null; } ...} HandlerExecutionChain 处理链管理者 Spring Web中需要一个角色,将处理器和拦截器链组织在一起,按照顺序处理请求。 它是一个管理处理器和拦截器链的组件。在处理请求时,它负责管理和调用所有的拦截器方法,并最终调用实际的处理器(例如 Controller)。 HandlerMapping 链的建立者 Spring Web中需要一个角色,负责从请求中确定合适的处理器,并将处理器与拦截器链组合起来,形成一个完整的处理链(也就是HandlerExecutionChain)。 它可以被视为该条责任链的启动点或“链的建立者”。它确定了请求将由哪个处理器处理,并且可能为处理器配置了一些拦截器。 总结 优点 业务解耦:每个节点独立可以随时新增或删除,不仅不会影响事务请求的业务代码,还不会影响其它责任节点。 责任单一:责任链每个节点处理的对象是同一种,但是每个节点处理的事务不一样。 动态组合:节点之间有强秩序,但是可以根据不同业务动态重新组合成一个新链路。 缺点 性能缺陷:节点之间有强秩序,大概率会走完全部链路,必影响耗时。 死循环:链路过长开发人员不熟悉整个流程一样难以新增节点,容易出现链路闭环。即新增的F节点可能下一步需要B, 但是要在C之前,便出现 B->D->C->F->B。当然这个问题在开发阶段能处理。

2024/6/10
articleCard.readMore

Tabby的Web前端与Gateway网关部署

前言 经常换设备用终端时候总是要下载 tabby 和添加原来的连接配置,还不同步。一直想搭建官方提供的tabby-web,现在终于有空搞。 搭建完发现不只是可以同步,还可以在网页连接配置里的终端,但是要搭建网关,顺便把网关也搭建了。web是http协议,网关是ws协议。 但是搭建过程和官方REDEME相比差别甚大 遂本文记载 tabby-web 与 tabby-gateway 的搭建配置与联动。 部署结果 准备 docker!docker!docker! 两份域名证书(前端和网关,前端的自己反向代理使用,网关的需要挂载到容器) 部署 先部署吧,碰到的小毛病都是部署后面的(只部署 tabby-web 的话自己删一下网关那段) 一起整合到了编排文件。 需要修改的地方如下,分是否开启SSL两种: 开启SSL 填写 网关证书目录地址:签名和私钥命名为gateway.pem和gateway.key 填写 前端容器挂载目录:后面要用到。 填写 两个映射的端口 填写 前端域名 填写 网关密钥(相当于自定义的密码) 填写 SOCIAL_AUTH_GITHUB_KEY 与 SOCIAL_AUTH_GITHUB_SECRET (用于用户Github登录,点进来进来创建一个,回调地址填前端域名) 取消SSL 修改 tabby-gateway.command: –token-auth –host 0.0.0.0 删除 tabby-gateway.volumes 修改 “网关端口:443” -> “网关端口:9000” 填写 网关密钥 填写 前端域名 填写 SOCIAL_AUTH_GITHUB_KEY 与 SOCIAL_AUTH_GITHUB_SECRET docker-compose.yml 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 version: "3.0" services: tabby-gateway: image: ghcr.io/eugeny/tabby-connection-gateway:master container_name: "tabby-gateway" command: --token-auth --host 0.0.0.0 --port 443 --certificate /custom/ssl/gateway.pem --private-key /custom/ssl/gateway.key environment: - TABBY_AUTH_TOKEN=网关密钥 ports: - "网关端口:443" volumes: - /你的网关证书地址:/custom/ssl restart: unless-stopped tabby-web: image: ghcr.io/eugeny/tabby-web:latest container_name: tabby-web restart: unless-stopped volumes: - /你的前端容器挂载目录:/data environment: - DATABASE_URL=sqlite:////data/db.sqlite3 - DEBUG=False - PORT=8080 - APP_DIST_STORAGE=file:///data - SOCIAL_AUTH_GITHUB_KEY=记得填 - SOCIAL_AUTH_GITHUB_SECRET=记得填 - com.centurylinklabs.watchtower.enable=true - traefik.enable=true - traefik.http.routers.app-tabby-web.tls=true - traefik.http.routers.app-tabby-web.tls.certresolver=cloudflare - traefik.http.routers.app-tabby-web.entrypoints=websecure - traefik.http.routers.app-tabby-web.rule=Host(`前端域名`) - traefik.http.routers.app-tabby-web.service=app-tabby-web - traefik.http.services.app-tabby-web.loadbalancer.server.port=9090 logging: driver: json-file options: max-size: 5m max-file: "5" ports: - "前端端口:8080" 编排就交给你了,部署完接下来分两步,先跑通web后讲gateway。 前端 安装依赖 前端警告⚠️ 访问前端后会肯定有一个提醒,我们来到tabby-web容器的命令行执行如下 web容器 1 /manage.sh add_version 1.0.187-nightly.1 下面是毛病吐槽,点击跳过碎碎念。 最新版其实是 1.0.197-nightly.1 ,但是容易暴毙。 安装完之后重启容器发现仍然是黑屏! 打开F12网络检查会发现找不到 /app-dist/1.0.187-nightly.1/ 下面的文件。 来到服务器前端容器挂载目录会发现有1.0.187-nightly.1目录,但是它的子目录是/tmpxxxx,而/tmpxxxx目录下面就是前端网络找不到的文件,是不是很抽象。 当你把文件/tmpxxxx目录下面文件移动到1.0.187-nightly.1下面,重启容器,你会发现前端不再黑屏,但是一直在加载,打开F12故技重施发现又要/tmpxxxx下面的文件,而且要的还是1.0.187-nightly.1依赖未解压的版本,实在是抽象。 好了我吐槽完了,下面厘清操作。 调整依赖 在前端容器挂载目录完成。 将 /1.0.187-nightly.1/tmpxxxx 下面的文件移动到 /1.0.187-nightly.1 移动文件 1 2 3 4 5 # 去目标目录 cd /前端容器挂载目录/1.0.187-nightly.1 # 查询tmpxxx名字 ls mv ./tmpxxxx/* ./ 下载源码,解压到 ./tmpxxxx 下载解压 1 2 3 4 # 去目标目录 cd ./tmpxxx # 下载解压 wget https://registry.npmjs.org/tabby-web-demo/-/tabby-web-demo-1.0.187-nightly.1.tgz tar -xvf tabby-web-demo-1.0.187-nightly.1.tgz 同步配置 自此重启容器,前端能加载完毕。 访问:你的域名/login。可以使用Github登录。 登陆之后点击设置有token,然后在客户端配置即可,参考下面图片,前者前端,后者客户端。 前端设置 客户端配置 网关 编排能通过网关就没问题,网关一直重启就是证书目录有问题。 注意:网关是WS协议,不要习惯去http反向代理,然后配置了证书的话网关地址应该是 wss://网关域名:网关端口 填写是在前端填写,这样就能在网页上用同步的配置连接终端,如图。 网页使用配置 网页通过网关连接终端 结语 遇事不决,欢迎提问。

2024/5/13
articleCard.readMore

vue运行时反向代理无限刷新

吐槽 本以为是一次习以为常的部署业务。结果要是再刷新下去,CPU都要爆了!!! 错误情景 开发版本: vue:2 开发环境:正常本地window开发。 bug:无。 生成环境:项目是运行时,运行在52002端口,nginx反向代理127.0.0.1:52002,挂载到域名开启ssl。 bug:访问域名会无限刷新网页。刷新时候可以看到网页有内容,抓住机会点击跳转也是正常路由跳转,跳转后依然开始无限刷新。 下面是部署项目部分配置代码 vue.config.js 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 const webpack = require('webpack') module.exports = {   publicPath: '/',   assetsDir: 'static',   productionSourceMap: true,   devServer: {     host: '0.0.0.0',     port: 52002,     proxy: {       '/proxy/api':{         target:'https://xxx.thatcoder.cn',         changeOrigin:true,         pathRewrite:{           '/proxy/api':'https://xxx.thatcoder.cn'         }       }     },     allowedHosts: ['localhost', '127.0.0.1', '0.0.0.0', 'xxx.thatcoder.cn'],   } }; 解决方案 严谨的说,仅提供博主排错误时看到的类似错误与解决方案收录。因为即使错误表现一致,错误原因可能不一致。 破局一 此方法解决博主问题。 执行打包命令,将反向代理的站点设置为打包的目录(默认是dist)。或者将打包文件复制到站点的目录下。(博主选择后者) 破局二 部分人是碰到运行时的热部署经过反向代理后无法通讯导致,热部署是ws协议,与项目共用端口。部分人通过配置关闭热部署能解决。 vue2和vue3关闭热部署 vue.config.js 1 2 3 4 5 module.exports = { devServer: { hot: false } } vite关闭热部署 vite.config.js 1 2 3 4 5 export default { server: { hmr: false } } 破局三 部分人是碰到运行时的热部署ws协议端口占用,与项目共用端口。部分人通过修改端口能解决。 修改热部署ws协议端口 vue.config.js 1 2 3 4 5 6 7 module.exports = { devServer: { client: { webSocketURL: 'ws://0.0.0.0:52003/ws' }, } }

2024/5/12
articleCard.readMore

OnlyOffice集成到Vue与Spring项目

基础环境 需安装依赖:@onlyoffice/document-editor-vue 需自行搭建OnlyOffice服务:https://office.thatcoder.cn/ (仅供测试使用,请勿用于生产环境,随时可能关闭) 补充:搭建OnlyOffice要开启ssl按官方的比较麻烦,可以移步底下目录有解锁版,调教了配置。 简单使用 一口吃不成胖子,先从最简单的DEMO,来测试所用服务的可靠性。 测试的DEMO,仅需要替换以下变量即可跑通测试。 documentSite:自行搭建的OnlyOffice服务地址。可用 https://office.thatcoder.cn/ 进行测试。 documentUrl: DOCX文档地址。 office-demo.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 <template> <DocumentEditor id="docEditor" :documentServerUrl="documentSite" :config="editorConfig" :events_onDocumentReady="onDocumentReady" /> </template> <script setup> import {ref} from "vue"; import { DocumentEditor } from '@onlyoffice/document-editor-vue'; import axios from "axios"; // DOCX文档地址 const documentUrl = ref('https://resumes.thatcdn.cn/public/template/resumes_001.docx'); const documentTitle = ref('论Vue如何使用OnlyOffice'); const documentType = ref('docx'); // 文档标识符 const documentKey = ref('your-document-key'); // 私有化的OnlyOffice地址 const documentSite = 'https://office.thatcoder.cn/' // Vue处理OnlyOffice事务的回调地址 const documentCallSite = 'https://www.baidu.com/' // 文档加载完毕的回调 const onDocumentReady = () => { console.log('Document is ready'); }; // 文档变更的回调 const onDocumentStateChange = (event) => { if (event.data === 'onDocumentStateChange') { saveDocument(); } }; // 处理文档另存为的回调事件 const onRequestSaveAs = (event) => { console.log('Document save as requested'); // 执行文档另存为的逻辑,例如弹出保存对话框等 }; // 插入图片的回调事件 const onRequestInsertImage = (event) => { console.log('Insert image requested'); // 执行插入图片的逻辑,例如打开图片选择器等 }; // 文档保存回调方法 const saveDocument = () => { axios.post('https://www.baidu.com/save-document', { documentUrl: documentUrl.value, documentType: documentType.value, documentKey: documentKey.value, }) .then(response => { console.log('Document saved successfully'); }) .catch(error => { console.error('Document save error:', error); }); }; // 初始化OnlyOffice const editorConfig = ref({ document: { title: documentTitle.value, url: documentUrl.value, fileType: documentType.value, key: documentKey.value, }, editorConfig: { mode: 'edit', lang: 'zh', callbackUrl: documentCallSite, }, events: { onReady: onDocumentReady, onDocumentStateChange: onDocumentStateChange, onRequestSaveAs: onRequestSaveAs, onRequestInsertImage: onRequestInsertImage, // 添加其他需要处理的回调事件 }, }); </script> 配置参数 Config提供了大量配置,不过社区版支持的参数不是特别多。 editorConfig:参数配置 document:当前文档参数 events:回调事件绑定 更多参考官方文档ONLYOFFICE API 文档 - Config office配置 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 const editorConfig = ref({ editorConfig: { mode: 'edit', lang: 'zh', customization: { anonymous: { request: true, label: "Guest" }, autosave: false, comments: true, compactHeader: true, compactToolbar: false, compatibleFeatures: true, customer: { address: "A small corner of China", info: "A member of the open source spirit of the Internet", logo: "https://resume.app.thatcoder.cn/favicon.ico", logoDark: "https://resume.app.thatcoder.cn/favicon.ico", mail: "thatcoder@163.com", name: "钟意", phone: 13305374721, www: "https://blog.thatcoder.cn" }, forcesave: false, goback: { blank: true, requestClose: false, text: "回到简历列表", url: "https://resume.app.thatcoder.cn/mine/folder" }, help: false, hideRightMenu: false, hideRulers: true, integrationMode: "embed", logo: { image: "https://resume.app.thatcoder.cn/favicon.ico", imageDark: "https://resume.app.thatcoder.cn/favicon.ico", imageEmbedded: "https://resume.app.thatcoder.cn/favicon.ico", url: "https://resume.app.thatcoder.cn" }, macros: true, macrosMode: "禁用", mentionShare: false, mobileForceView: true, plugins: false, toolbarHideFileName: true, toolbarNoTabs: true, // uiTheme: "theme-dark", unit: "厘米", zoom: 100 }, // callbackUrl: 'https://resume.app.thatcoder.cn/onlyoffice-callback', callbackUrl: 'https://resume.thatapi.cn/office/callback', }, document: { title: documentName, url: documentUrl, fileType: documentType, key: documentKey, info: { favorite: false, folder: "" + thatLocal.getItem('userId'), owner: "" + thatLocal.getItem('userName'), sharingSettings: [ { permissions: "Full Access", user: "" + thatLocal.getItem('userName') } ], uploaded: "2010-07-07 3:46 PM" } }, events: { onReady: onDocumentReady, // onDocumentStateChange: onDocumentStateChange, onRequestSaveAs: onRequestSaveAs, // onRequestInsertImage: onRequestInsertImage, // 添加其他需要处理的回调事件 }, // documentServerUrl: 'https://office.thatcoder.cn', // 指定 ONLYOFFICE 服务器的网址 }); 回调服务 回调服务是指OnlyOffice与你的服务端的通讯地址,主要用于处理文档的保存、另存为等事件。 这里实例用spring演示,仅供参考,逻辑部分请自行编写。 建议边参考官方文档看代码。ONLYOFFICE API 文档 - 回调处理程序 OfficeCallbackDemoController.java 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 package cn.onestack.project.office.controller; import cn.hutool.json.JSONObject; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.io.PrintWriter; import java.util.Scanner; @RestController @RequestMapping("/office") public class OfficeCallbackDemoController { private static final String SAVE_DIRECTORY = "/path/to/save/directory"; // 替换为你的保存路径 @RequestMapping("/callback") public void callback(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { PrintWriter writer = response.getWriter(); Scanner scanner = new Scanner(request.getInputStream()).useDelimiter("\\A"); String body = scanner.hasNext() ? scanner.next() : ""; JSONObject jsonObj = new JSONObject(body); System.out.println(jsonObj); if(jsonObj.getInt("status") == 2) { System.out.println("Office文档可保存"); } else if(jsonObj.getInt("status") == 1) { System.out.println("Office文档已连接"); } else if(jsonObj.getInt("status") == 4){ System.out.println("Office文档已断开"); } System.out.println(jsonObj); writer.write("{\"error\":0}"); } } 社区解锁版 镜像地址 出自oo中文交流群,企鹅群号 186184848。 国内地址1:docker pull registry.cn-hangzhou.aliyuncs.com/186184848/documentserver 国内地址2:docker pull registry.cn-hangzhou.aliyuncs.com/miiror/only-office:v8.0.1 DockerHub:docker pull 186184848/documentserver 修改说明 基于docker镜像制作,最新版本号: 8.0.1 关闭地址过滤,导入镜像后可以完美通过IP局域网运行(在7.4以上版本默认无法通过IP访问) 去除最大在线编辑人数限制 完美解锁手机端浏览和编辑(无需设置手机UA为电脑模式) 添加中文字体,加入了常用的十几种字体 支持http/https快速部署(默认开启ssl非常繁琐) 优化文档打开速度(进一步优化,打开速度提升20%) 最大支持300M文档在线编辑(7.5以上新增)(默认30M)。 关闭SSL证书校验,自签证书也可以直接使用了。(7.6新增) 使用说明 镜像包含amd64,arm64架构 运行镜像遇到启动不起来问题报端口错误需要加上命令:–privileged=true 相关教程文档请参考:《从零使用onlyoffice及各类网盘》 插件扩展 未来写吧,有需要的可以参考官方文档。

2024/5/12
articleCard.readMore

PlanetScale的免费Hobby计划即将结束

如题 三封邮件,十分钟一封,赶紧停下代码迁移数据库。 之前临时测试 waline 评论的数据,就部署在 PlanetScale,没想到一用就是一年。 白嫖这么久怪不好意思的。 不会有博友也是PlanetScale吧。

2024/3/22
articleCard.readMore

Stellar 1.18.5 迁移到 latest

前言 不得不说,从1.18迁移到1.23变化挺大。作者xaoxuu辛苦了。 迁移工作 考虑长期使用stellar,就fork了一个分支持续跟进作者的更新。 变化 很多细节变化吧,这里备注一下巨变。 references写法改变 我wiki大量使用了参考文献功能,给出正则表达式批量替换方法 查找:- title: '(.*?)'\n url: '(.*?)' 替换:- '[$1]($2)' 也不是万能的,如果标题有特殊字符违背markdown写法可能报错,但剩下几个特殊的手动改就行。 页尾license _stellar.config.yml 1 2 3 article: license: | 本文为 [${author.name}](${author.url}) 撰写,采用 [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/) 许可协议,转载请注明出处。 作者 _data/authors.yml 1 2 3 4 5 6 # 作者 1 (默认) ThatCoder: name: '钟意' avatar: /custom/img/author.jpg banner: https://upyun.thatcdn.cn/myself/typora/202401120247887.webp description: 感谢你的阅读, 让我们拥有一段对彼此都有意义的时光. friends标签 friends标签的分组需要单独一个yml文件 sites也一样 wiki系统 这个一开始有点绕,我整理了一下逻辑。 _data/wiki.yml 的列表名字如 pro_name 指向 _data/wiki/pro_name.yml 的文件名字 _data/wiki/xxx.yml 文件里面的 path: /wiki/pro_path/ 参数指向 source/wiki/pro_path/ 文件夹 source/wiki/pro_path/ 文件夹内文件的 wiki: pro_name 闭环指向 _data/wiki.yml 的列表名字 综上 _data/wiki.yml 和 _data/wiki/pro_name.yml 和 文件wiki: pro_name 需要一致是 pro_name 而最终上线的项目在线 url 与 pro_name 无关,关联的是 source/wiki/pro_path/ 对应的 pro_path 目录名称 其他功能 来不及一个一个试功能,先写到这,便把博客更新到1.23.0 万能时间线重构 文档待写, 测试中 联合测试 netease `{ "api": "https://netease.thatapi.cn/user/event?uid=134968139&limit=10" }` memos `{ "api": "https://memos.thatcoder.cn/api/v1/memos?filter=creator%3D%3D%27users%2F1%27&pageSize=10" }`

2024/1/8
articleCard.readMore

微信读书自动阅读

前言 本文章实现需要服务器, 无可视化界面亦可。 使用的Cookie获取上一篇文章有介绍, 顺手写了这篇。 每日一问: 我为什么要实现这个功能??? 微信读书Cookie续活https://blog.thatcoder.cn/Tencent-WxRead-Cookies/ 机制分析 网页版状态下阅读, 每分钟左右会有一个read请求, 通过回执可以判断是否阅读成功。 具体参数我不想耗费时间去逆向, 但是可以通过模拟浏览阅读页面来等待read响应进行read重播,进而轻易实现自动阅读。 稳定性 服务器测试了24小时, 阅读时间也是相应增加24。 有趣的是, 经测试, 每次程序运行5min, 增加的时长可能是 5min、6min、8min、11min、13min 甚至是 21min。 但是总时长是稳定的, 也就是说会回归一天能拉满的时间24h。 实现代码 虽说是浏览器模拟事件, 到了python的表演时间, 但是我采用了JS去写, 辅佐包是 Playwright 。 总体是一次有趣的尝试。 准备事项 开始吧, 安装 Playwright 1 2 3 4 5 6 7 8 9 10 11 12 13 14 # 先创建一个文件夹 mkdir /server/auto/wxread && cd /server/auto/wxread # 安装 playwright npm install playwright npx playwright install # 下面这个可能需要点时间 # 因为有浏览器的下载 npx playwright install-deps # 当然少不了 axios npm install axios # 好的, 一切准备就绪, 创建代码吧 代码 wxread.js 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 const { firefox } = require('playwright'); const axios = require('axios'); // 获取命令行参数 const args = process.argv.slice(2); const params = {}; args.forEach((arg) => { const [key, value] = arg.split('='); if (key && value) { params[key] = value; } }); const url1 = 'https://weread.qq.com/web/reader/8f5329e0813ab7d1eg012feake4d32d5015e4da3b7fbb1fa'; const url2 = 'https://weread.qq.com/web/book/read'; let capturedResponse = null; let browser = null; const scrollInterval = 10000; // 上下滑动间隔时间 单位毫秒 const totalTime = 400000; // 单次阅读时间 单位毫秒 const getXHR = async () => { console.log("Success: 启动 Playwright 浏览器"); browser = await firefox.launch({ headless: true, }); const page = await browser.newPage(); await page.setExtraHTTPHeaders({ cookie: (await axios.get("https://sijnzx.laf.thatcoder.cn/tencent-weread-refcookie?key="+params['key'])).data["data"]["cookies"] }); await page.goto(url1, { waitUntil: 'networkidle', }); console.log("Success: 打开内容页面"); page.on('response', async (response) => { if (response.url() === url2) { const data = await response.json(); if (data['succ'] === 1) { console.log("Success: 目标URL响应成功"); } else { console.log("Error: 目标URL响应失败"); } capturedResponse = data['succ'] === 1 ? response : null; await repeatXHR(100); // 不要关闭浏览器 } }); // 定期上下滑动 let scrollCount = 0; // 计数器 let scrollDirection = 1; // 1表示向下滑动,-1表示向上滑动 setInterval(async () => { await page.evaluate((scrollDirection) => { const windowHeight = window.innerHeight; window.scrollBy(0, scrollDirection * windowHeight); // 向上或向下滑动一个屏幕高度 }, scrollDirection); scrollCount++; // 如果达到了五次滑动,切换方向并重置计数器 if (scrollCount === 5) { scrollDirection *= -1; // 切换方向 scrollCount = 0; // 重置计数器 } }, scrollInterval); // 设置浏览器关闭定时器 setTimeout(async () => { console.log("Success: 关闭浏览器"); await browser.close(); }, totalTime); }; const repeatXHR = async (count) => { if (!capturedResponse) { console.log("Failed: 没有捕获到响应,无法重放"); return; } const request = capturedResponse.request(); for (let i = 0; i < count; i++) { try { const response = await axios({ method: request.method(), url: request.url(), headers: request.headers(), params: request.params, data: request.postData(), }); if (response.data.succ !== 1) { console.log(`Failed: 重放响应 ${i + 1}: 失败, succ!==1`); return; } } catch (error) { console.error(`Failed: 重放响应 ${i + 1}: 失败, ${error.message}`); } } console.log(`Success: 重放响应 ${count} 次完毕`) }; (async () => { await getXHR(); })(); 运行 代码会启动一个无头浏览器, 所以没有可视化也不需要担心。 个人测试24小时, 无任何问题, 使用的内存为300MB左右, CPU占用率为0.1%左右。 对了, 带上key参数是我接口的鉴权, 也就是上一篇文章的参数(个人有所修改)。 你实现了上一篇文章的获取可以使用你的接口。保证cookie是有效的即可。 1 2 3 4 5 6 7 8 9 10 node wxread.js key=xxxx # 成功运行大概输出如下 # Success: 启动 Playwright 浏览器 # Success: 打开内容页面 # Success: 目标URL响应成功 # Success: 重放响应 100 次完毕 # Success: 目标URL响应成功 # Success: 重放响应 100 次完毕 # Success: 浏览器关闭 (400秒后)

2023/9/14
articleCard.readMore