技能 vs MCP:Hermes Agent 扩展机制的选型指南

在和 Hermes Agent 的日常对话中,我发现它有两种扩展能力的方式:**技能(Skill)**和 MCP(Model Context Protocol)。我一直没搞清楚——两者看起来都是「让 Agent 能做更多事情」,到底有什么区别?什么时候用哪个?

跟 Hermes 深入聊了一轮,结论比我想象的清晰。

技能就是一个纯文本方法论?

是的。技能的本质就是一份 Markdown 文件,放在 ~/.hermes/skills/ 目录下。比如 claude-code 技能,就是一个 745 行的 SKILL.md,带 YAML 元数据头:

1
2
3
4
5
---
name: claude-code
description: "Delegate coding to Claude Code CLI"
version: 2.2.0
---

除此之外没有任何代码。

技能怎么工作?

三步:

  1. 存储 — Hermes 启动时扫描技能目录,建立索引
  2. 发现 — 当你提到「用 Claude Code」,LLM 的系统提示里出现了匹配的技能摘要
  3. 加载 — LLM 调用 skill_view(name),Hermes 把整个 SKILL.md 的内容注入上下文窗口

到这一步,LLM 就「学会」了 Claude Code CLI 的所有参数、两种编排模式、安全注意事项……然后它通过已有的 terminal 工具去执行 claude 命令。

技能不创造新工具。 加载 claude-code 技能后,LLM 的工具列表没变化,还是 terminalwrite_fileread_file 这些。技能只是让 LLM 知道怎么组合现有工具

那技能依赖本地安装的工具?

是的。如果机器上没装 claude 二进制,技能里记的那些 --max-turns--allowedTools 全是纸上谈兵。LLM 执行 terminal(command="claude -p ...") 只会得到一个 command not found

技能 = 教 LLM 方法。实际干活靠系统里已有的工具。

那 MCP 又是什么?

MCP 是 Anthropic 提出的开放协议,让你可以给 AI Agent 接入新工具。它需要一个运行中的后台服务(MCP Server),Agent 通过 JSON-RPC 协议跟它通信。

MCP 怎么工作?

1
2
3
4
5
6
7
MCP Server (守护进程)
├── 启动时建立数据库连接池
├── 注册工具:query, insert, migrate...
└── 等待 Agent 调用
↑ JSON-RPC
Hermes Agent
└── 工具列表里多了 mcp__postgres__query(...)

MCP 实实在在地增加了 LLM 的工具集。调用 mcp__postgres__query 是直接跟后台服务通信,不是拼接 shell 命令。

核心差异一图看懂

维度 技能 MCP
本质 静态方法论文档 运行时工具协议
给谁用 LLM 自己看,学习怎么做 LLM 调用,获得新能力
运行时 无——就是读文件 需要常驻进程
工具变化 工具列表不变 增加新工具
状态 无状态 有状态(连接池、会话)
比喻 菜谱 新厨具

三张图看清调用链路

技能调用链路

1
2
3
4
5
6
7
8
9
10
用户说「用 Claude Code 审查代码」


skill_view("claude-code") ← 注入 34KB 使用手册


terminal("claude -p 'review'") ← 还是用 terminal


stdout 返回结果

MCP 调用链路

1
2
3
4
5
6
7
8
9
10
用户说「查一下最近的错误日志」


mcp__log__query(since="1h") ← 这个工具本来不存在!


JSON-RPC → MCP Server → 数据库


结构化 JSON 返回

两种模式叠加

1
2
3
4
5
6
7
8
9
10
11
12
用户请求


Hermes Agent

├── 技能系统:注入知识,教我怎么用现有工具
│ └── skill_view() → 上下文膨胀

├── MCP 系统:提供新工具,扩展我的能力边界
│ └── mcp__<server>__<tool>() → 工具膨胀

└── 内置工具:terminal, read_file, write_file, browser...

两者互补:技能加智力,MCP 加武力

那日志查询到底用技能还是 MCP?

这是我最初的困惑。答案比我想的简单——命令行 + 技能就行

日志查询是按需触发的:

1
2
3
4
5
查最近一小时的错误日志

加载技能 → terminal 调脚本 → 拿到结果 → 完事

进程退出,资源释放

MCP 要一个常驻进程蹲在那,99% 时间空转,就为了偶尔查一次日志——划不来。

MCP 到底什么时候用?

跟 Hermes 聊完,结论收敛为三个硬性条件:

需要持久连接

典型:数据库、Redis。每次调用都重新建立连接不现实——建立连接 100ms,复用连接池 1ms,差两个数量级。

需要双向实时通道

不是「我问你答」,而是「有事情发生你主动告诉我」。典型:GitHub MCP 监听 PR 状态变化、文件系统监控、实时日志 tail。CLI 方案只能轮询——有时差、浪费资源。

CLI 做不了的事情

典型:浏览器操作。terminalcurl 能拿 HTML,但渲染不了 JavaScript。MCP Puppeteer 给的是真正的浏览器实例,可以点按钮、填表单、截图。

一张决定流程图

1
2
3
4
5
6
7
                    ┌─ 需要持久连接(DB/缓存)?───→ MCP

需要新能力 ──→ 需要实时推送?──────────────→ MCP

├─ CLI 做不了(浏览器/GUI)?──→ MCP

└─ 以上都不是 ────────────────→ CLI + Skill

一句话总结

用 MCP 买的不是功能,是「状态」和「连接」。 如果你的工具可以写成无状态的「接收参数→返回结果」脚本,CLI + Skill 就够了。

花十分钟写个脚本 + 五十行 SKILL.md,跟折腾 MCP Server 的常驻进程、协议实现、保活运维相比,前者的性价比碾压后者。只有当你真的需要一个永远在线的会话时,MCP 才值得那个开销。

从HTTP到HTTPS:一个博客的升级之路

一、HTTP 有什么问题?

我的博客 blog.kentonly.com 跑了很久,一直是纯 HTTP。直到有一天,我发觉这样不安全。

HTTP 是明文传输的。你在浏览器里输入的每一个字、服务器返回的每一行 HTML,在网络上都以可读的明文形式经过无数路由器、交换机、运营商节点。任何一个中间节点都可以:

问题 1:窃听

1
2
3
4
你 ──"密码是123456"──▶ 路由器 ──"密码是123456"──▶ 服务器

运营商/中间人
"哦,他的密码是123456"

HTTP 报文没有任何加密,抓包工具一抓一个准。如果我在咖啡馆连公共 WiFi,同一个网络的人用 Wireshark 就能看到我在看什么、输什么。这不是”黑客攻击”——HTTP 协议本身就是透明的。

问题 2:篡改

更恶心的是,中间人不光能看,还能

1
2
3
4
5
你 ◀── index.html ──── 运营商 ◀── index.html ──── 服务器
│ │
│ 页面里被插入了: │
│ <script src="广告.js"> │
│ <div>流量包优惠</div> │

很多 ISP 确实在干这种事——在你访问的网页里插入广告、弹窗、流量提醒。你以为是网站放的,其实是你家宽带运营商塞进去的。

问题 3:冒充

更危险的:如果有人伪装成你的目标网站呢?

1
2
3
4
你 ──"给我百度首页"──▶ 中间人 ──"给我百度首页"──▶ 百度
你 ◀── 假百度页面 ──── 中间人 ◀── 真百度首页 ──── 百度

你输了密码,中间人拿到了

这叫 MITM(中间人攻击)。HTTP 没有任何机制让你确认”跟我说话的确实是百度”。

三大问题总结:明文 → 能看、能改、能装。


二、HTTPS 怎么解决这些问题的?

HTTPS 本质上是 HTTP over TLS——在 HTTP 下面加了一层 TLS 协议。TLS 管三件事:

问题 TLS 的方案 怎么做到的
窃听 加密 握手后双方用对称密钥(AES)加密所有数据,中间人看到的是乱码
篡改 完整性校验 每条消息带 HMAC 校验码,改了哪怕一个字节都能检测出来
冒充 身份验证 CA 签发的证书证明”你就是你说的那个网站”

前两件事靠密码学。第三件事靠证书——也就是本文的核心。


三、证书是什么?浏览器怎么验证它?

3.1 证书 = 网站的身份证

放到现实世界来类比:

  • 你去银行办业务,银行要求你出示身份证
  • 你上网访问百度,浏览器要求百度出示证书

身份证有公安局盖章(防伪造),证书有 CA 签名(防伪造)。公安局是你信任的权威机构,CA 是你浏览器信任的权威机构。

3.2 证书长什么样

openssl 看一眼我博客的 Let’s Encrypt 证书:

1
2
3
4
5
6
7
8
$ openssl x509 -in /etc/letsencrypt/live/blog.kentonly.com/cert.pem -text -noout

Subject: CN = blog.kentonly.com ← 证书持有者(我的博客)
Issuer: CN = R3, O = Let's Encrypt ← 谁签发的
Not Before: Jul 11 01:51:02 2026 GMT ← 生效时间
Not After : Oct 9 01:51:01 2026 GMT ← 过期时间(90 天)
Public Key: ECC P-256 公钥 ← 核心:我的公钥
Signature: [CA 用私钥签的名] ← 防伪标记

最关键的三个字段:域名公钥CA 签名。缺一不可。

3.3 证书是怎么签发的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
我(blog.kentonly.com)

│ ① 生成公私钥对,私钥自己留着
│ ② 把"公钥 + 域名"提交给 Let's Encrypt


Let's Encrypt(CA)

│ ③ 验证我真的拥有 blog.kentonly.com
│ (在我网站目录放一个临时文件,Let's Encrypt 来访问它)
│ ④ 验证通过后,用 CA 的私钥签名:

│ signature = Sign_CA私钥(域名 + 公钥 + 有效期 + 序列号 + ...)

│ ⑤ 把签名后的证书发回给我


我把证书部署到 Nginx,每次有人访问,Nginx 就把证书发过去

核心理解:CA 的签名绑定的是”域名和公钥的对应关系”。它证明”这个公钥是属于 blog.kentonly.com 的”。

3.4 浏览器拿到证书后怎么验证

当你访问 https://blog.kentonly.com,服务器不只发站点证书,还发整条证书链

1
2
3
4
5
6
7
8
9
┌───────────────────────────────────┐
│ 1. blog.kentonly.com 站点证书 │ ← 我的
│ 签名者: Let's Encrypt R3 │
├───────────────────────────────────┤
│ 2. Let's Encrypt R3 中间证书 │ ← 中间 CA
│ 签名者: ISRG Root X1 │
├───────────────────────────────────┤
│ 3. ISRG Root X1(在浏览器里) │ ← 根 CA,浏览器预装
└───────────────────────────────────┘

浏览器逐级验证,一共五关:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
浏览器收到证书链

├─ [1] 签名验证:用 R3 的公钥验站点证书的签名 → hash1 == hash2?
│ NO → ❌ NET::ERR_CERT_AUTHORITY_INVALID
│ YES ↓

├─ [2] 信任链验证:用 ISRG Root X1 的公钥验 R3 的签名
│ ISRG Root X1 在浏览器预装列表里吗?
│ NO → ❌ NET::ERR_CERT_AUTHORITY_INVALID
│ YES ↓

├─ [3] 时效验证:not_before < now < not_after?
│ NO → ❌ NET::ERR_CERT_DATE_INVALID
│ YES ↓

├─ [4] 域名验证:证书 SAN 列表包含 blog.kentonly.com?
│ NO → ❌ NET::ERR_CERT_COMMON_NAME_INVALID
│ YES ↓

└─ [5] 吊销检查(可选):CA 有没有把这证书吊销了?
YES → ❌ 证书已吊销
NO → ✅ 显示 🔒,建立 TLS 连接

五关全过,浏览器才显示绿锁。 任何一关失败,直接红屏。

其中最关键的是第 2 关——信任链。浏览器的”信任锚”是出厂预装的约 150 个根 CA 证书。Let’s Encrypt 的根 ISRG Root X1 就在其中。这就是为什么你用 Let’s Encrypt 签的证书能被全球浏览器信任,而你自己用 openssl 自签的证书只能在自己电脑上信。


四、TLS 握手是怎么建立加密通道的

证书验证了身份,接下来要建立加密通道。TLS 握手(简化版):

1
2
3
4
5
6
7
8
9
10
11
客户端                              服务器
│ │
│── 1. ClientHello ──────────────▶│ 支持的 TLS 版本、加密套件
│ │
│◀── 2. ServerHello ─────────────│ 选 TLS 1.3 + ECDHE + AES-GCM
│◀── 3. 证书链 ─────────────────│ 站点证书 + R3 中间证书
│◀── 4. 密钥交换参数 ───────────│ ECDHE 公钥参数
│ │
│ 双方计算会话密钥(ECDHE) │
│ │
│◀════ 5. 加密通信 ════════════▶│ AES-GCM 对称加密

关键理解:

  • 非对称加密只用一次(握手阶段,交换密钥)
  • 之后全部对称加密(AES,性能高)
  • 即使中间人抓到了所有握手包,也算不出会话密钥(ECDHE 的前向安全性)

五、实战:给 blog.kentonly.com 加 HTTPS

现在理论讲完了,回到现实。我的博客环境:

  • 服务器:阿里云 Linux
  • Web 服务器:Docker nginx:alpine,80 端口
  • 静态文件:/root/nginx-docker/websites/blog.kentonly.com/
  • 域名已解析

步骤 1:安装 Certbot

1
yum install -y certbot

步骤 2:申请证书

Let’s Encrypt 免费。用它验证域名所有权的方式选 webroot——在你网站目录放一个验证文件,Let’s Encrypt 来访问它确认域名是你的。

1
2
3
4
5
certbot certonly --webroot \
-w /root/nginx-docker/websites/blog.kentonly.com \
-d blog.kentonly.com \
--email kentonly@foxmail.com \
--agree-tos --non-interactive

成功后证书在这里:

1
2
3
4
5
/etc/letsencrypt/live/blog.kentonly.com/
├── fullchain.pem ← Nginx 用这个(证书 + 中间证书)
├── privkey.pem ← 私钥,绝对不能泄露
├── cert.pem ← 站点证书
└── chain.pem ← 中间证书

步骤 3:配置 Nginx

我之前的配置只有 80 端口,加上 443 端口的 HTTPS server 块,以及 HTTP → HTTPS 的 301 跳转:

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
# HTTP → HTTPS 跳转
server {
listen 80;
server_name blog.kentonly.com;

location /.well-known/acme-challenge/ {
root /var/www/blog.kentonly.com; # certbot 续期需要用
}
location / {
return 301 https://$host$request_uri;
}
}

# HTTPS
server {
listen 443 ssl;
server_name blog.kentonly.com;

ssl_certificate /etc/letsencrypt/live/blog.kentonly.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/blog.kentonly.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:...;

root /var/www/blog.kentonly.com;
index index.html;

location / {
try_files $uri $uri/ =404;
}
}

注意 .well-known/acme-challenge/ 路径——这是给 certbot 续期时验证域名用的,配到 HTTP 的 80 端口就行,续期不需要 443。

步骤 4:重建 Nginx 容器

原来的容器只映射了 80 端口,加 443 端口 + 挂载证书目录:

1
2
3
4
5
6
7
8
9
10
docker stop website-nginx && docker rm website-nginx

docker run -d --name website-nginx \
--restart unless-stopped \
-p 80:80 \
-p 443:443 \
-v /root/nginx-docker/websites:/var/www \
-v /root/nginx-docker/conf:/etc/nginx/conf.d \
-v /etc/letsencrypt:/etc/letsencrypt:ro \
nginx:alpine

步骤 5:阿里云安全组开 443 端口

去阿里云控制台 → 安全组 → 入方向 → 添加规则:端口 443,协议 TCP,授权对象 0.0.0.0/0。这步容易被忽略,忘了就没法从外网访问 HTTPS。

验证

1
2
3
4
5
6
$ curl -sI https://blog.kentonly.com
HTTP/1.1 200 OK

$ curl -sI http://blog.kentonly.com
HTTP/1.1 301 Moved Permanently
Location: https://blog.kentonly.com/

HTTPS 返回 200,HTTP 自动跳转到 HTTPS。浏览器地址栏出现 🔒。


六、证书自动续期

Let’s Encrypt 证书有效期只有 90 天。但不需要每 90 天手动续——certbot 自带续期逻辑

6.1 配置

一行 cron,每天凌晨 3 点跑:

1
2
# crontab -e
0 3 * * * certbot renew --quiet --post-hook "docker exec website-nginx nginx -s reload"

--post-hook 的作用:证书续期后,Nginx 还在读旧证书文件(因为文件内容变了但 inode 变了,Nginx 缓存的是旧 inode),重载一下让 Nginx 读到新证书。

6.2 certbot renew 的内部逻辑

很多人以为每天 3 点都在续期,其实不是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
每天 3:00 certbot renew 启动

├─ 读取 /etc/letsencrypt/renewal/blog.kentonly.com.conf
│ 里面有申请时的所有参数(验证方式、域名、路径等)

├─ 检查 /etc/letsencrypt/live/blog.kentonly.com/cert.pem
│ 离过期还有 60 天?

│ 是的 → 跳过("Certificate not yet due for renewal")
│ → 什么都不做,退出

│ 不,只剩 29 天了 → 触发续期 ↓

├─ ① 生成全新的密钥对
├─ ② 把新公钥 + 域名发给 Let's Encrypt
├─ ③ Let's Encrypt 再验证一次域名(还是 webroot 方式)
├─ ④ 验证通过 → 签发新证书(90 天有效期)
├─ ⑤ 新证书存入 /etc/letsencrypt/archive/blog.kentonly.com/cert2.pem
├─ ⑥ 更新符号链接 live/cert.pem → archive/cert2.pem

└─ 执行 --post-hook
docker exec website-nginx nginx -s reload

6.3 关键细节

触发时机:不是”提前一天”,而是提前 30 天。给足了容错空间——即使某天 cron 没跑、网络出问题、Let’s Encrypt 服务挂了,还有 30 天窗口可以修复。设成”提前一天”的话,任何一个环节出点问题就过期了。

每次续期是新密钥:不是延长旧证书的有效期(证书签发后就不可改了),而是重新申请一张全新的证书,用全新的密钥对。这样即使旧私钥泄露过,新证书也是安全的——私钥定期轮换。

验证不偷懒:即使之前已经验证过域名所有权,续期时 Let’s Encrypt 照样会再验一遍。不存在”上次验证过了这次就跳过”——这也是安全设计。

旧证书保留archive/ 目录里保留所有历史版本,万一新证书有问题可以手动切回旧证书。


七、总结

从 HTTP 到 HTTPS,本质上就是解决三个问题:窃听、篡改、冒充

证书解决”冒充”——它把域名和公钥绑在一起,由浏览器信任的 CA 签名担保。TLS 握手解决”加密”和”防篡改”——用非对称加密安全交换密钥,再用对称加密高效通信。

对一个个人博客来说,实操就六步:装 certbot → 申请证书 → 配 Nginx → 开 443 端口 → 配置自动续期 → 验证。前四步是一次性的,后两步让系统自己跑。

我的博客现在已经全站 HTTPS。你在浏览器里看到的那把 🔒,背后是 CA 签名、TLS 握手、证书验证链条——每一步都值得弄明白。


参考:本文实操基于 blog.kentonly.com 的部署环境(Docker Nginx + Let’s Encrypt + Certbot)

Chrome插件下载微信公众号文章:原理与实现

一、问题背景

我有一个微信公众号”默耘成长社”,想把自己的全部文章导入向量数据库,做一个能搜我历史文章的 AI 问答系统。

微信公众平台提供了 API,但我的个人订阅号没认证,freepublish/batchget 接口在 2025 年 7 月被微信官方回收了权限。更糟的是,从服务器 IP 直接访问 mp.weixin.qq.com/s/xxx 文章页面,会被微信的风控系统拦截,返回「环境异常」验证页面。

无论换什么 User-Agent、加 Referer、Playwright + stealth 、甚至 Cookie 预热,全被拦死——微信是 IP 级别的风控。

但我发现一条路:在自己已登录的 Chrome 浏览器里,文章页面能正常打开。 那就写一个 Chrome 扩展,利用浏览器的登录态来下载。

二、整体思路

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
┌──────────────────────────────────────────────────┐
│ 用户浏览器(已登录微信后台) │
│ │
│ ┌──────────┐ ┌──────────────┐ ┌─────────────┐ │
│ │ popup.js │ │ content.js │ │background.js│ │
│ │ (弹窗UI) │──│ (注入到后台) │──│ (下载文件) │ │
│ └──────────┘ └──────┬───────┘ └─────────────┘ │
│ │ │
│ ┌───────────┼───────────┐ │
│ ▼ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ list_ex │ │ 文章页面 │ │ 文章页面 │ │
│ │ 内部API │ │ iframe 1 │ │ iframe 2 │ ... │
│ └──────────┘ └──────────┘ └──────────┘ │
└──────────────────────────────────────────────────┘

三步走:

  1. 拿文章列表——调用微信后台内部 AJAX 接口(带浏览器 Cookie,不触发风控)
  2. 逐篇抓正文——创建隐藏 iframe 加载文章阅读页,同源带 Cookie,提取 DOM 内容
  3. 打包下载——全部抓完后,JSON 文件通过 chrome.downloads API 触发浏览器下载

三、关键技术实现

3.1 Manifest V3 权限

1
2
3
4
5
6
7
8
9
{
"manifest_version": 3,
"permissions": ["activeTab", "scripting", "downloads", "storage"],
"host_permissions": ["https://mp.weixin.qq.com/*"],
"content_scripts": [{
"matches": ["https://mp.weixin.qq.com/*"],
"js": ["content.js"]
}]
}

content_scriptscontent.js 在用户每次打开微信公众号后台页面时自动注入。注入后就能访问页面的 DOM、Cookie、以及发出同源请求。

3.2 获取文章列表——调用微信内部 API

打开 Chrome DevTools 的 Network 面板,在公众号后台翻页时能看到一个请求:

1
POST /cgi-bin/appmsg?action=list_ex&begin=0&count=5&type=9&token=xxx&lang=zh_CN&f=json&ajax=1

返回的 JSON 长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"app_msg_cnt": 58,
"app_msg_list": [
{
"aid": "1234567890",
"appmsgid": 2247484232,
"title": "一个顶级思维方法:帮你立刻消除烦恼",
"link": "http://mp.weixin.qq.com/s?__biz=...",
"create_time": 1749363911,
"cover": "https://mmbiz.qlogo.cn/...",
"digest": "我们应该这样生活..."
}
]
}

这个接口不需要额外的 API 权限——它就是你打开后台页面时,前端 JS 自己调的那个接口。我们只是借用浏览器的 Cookie 替我们调。

Token 怎么拿? Token 就在当前页面的 URL 参数或某个 <script> 标签里。在 content.js 里直接读:

1
2
3
4
5
6
7
8
9
10
11
12
13
function extractToken() {
// 方法1:URL 参数
const params = new URLSearchParams(location.search);
let token = params.get('token');
if (token) return token;

// 方法2:页面内嵌 JavaScript 变量
for (const script of document.querySelectorAll('script')) {
const m = script.textContent.match(/token\s*[:=]\s*["'](\d{8,})["']/);
if (m) return m[1];
}
return null;
}

有了 token,fetchcredentials: 'include' 调用上述 API,自动翻页,就能拿到全部文章列表。

3.3 获取文章正文——iframe 同源加载

列表 API 只返回元数据(标题、链接、摘要),不含正文。正文要从文章阅读页(mp.weixin.qq.com/s/xxx)提取。

为什么不能用 fetch? 因为从服务器 IP fetch 文章页面会被风控拦截。但我们是在用户已登录的浏览器里,Cookie 有效,不会被拦。

问题来了:就算在浏览器里,fetch(articleUrl) 有时候也会触发验证页面。可能是因为 fetch 不带完整的浏览器上下文,微信的风控检测到异常。

解决方案:iframe 同源嵌入

既然是同源(mp.weixin.qq.com),在后台页面里塞一个隐藏 iframe,指向文章阅读页,等加载完直接读 DOM:

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
async function fetchArticleContent(article) {
return new Promise((resolve) => {
const iframe = document.createElement('iframe');
iframe.style.cssText = 'position:fixed;top:-9999px;left:-9999px;width:1px;height:1px;';
iframe.src = article.link;

const timeout = setTimeout(() => {
article.no_content = true;
resolve(article);
}, 8000);

iframe.onload = () => {
try {
const doc = iframe.contentDocument;
const html = doc.documentElement.outerHTML;
// 从 HTML 中提取 var msg_title、#js_content 等
extractFromHTML(html, article);
} catch(e) {}
clearTimeout(timeout);
iframe.remove();
resolve(article);
};

document.body.appendChild(iframe);
});
}

iframe 的 contentDocument 在同源情况下可以直接访问,微信文章页面结构很规整:

  • var msg_title = "..." — 标题
  • var nickname = "..." — 作者
  • var msg_create_time = "..." — 发布时间
  • #js_content div — 正文 HTML

用正则提取就行:

1
2
3
4
5
6
7
function extractFromHTML(html, article) {
const t = html.match(/var\s+msg_title\s*=\s*(['"])([\s\S]*?)\1/);
if (t) article.title = decodeURIComponent(JSON.parse('"' + t[2] + '"'));

const contentMatch = html.match(/<div[^>]*id="js_content"[^>]*>([\s\S]*?)<\/div>/i);
if (contentMatch) article.content_html = contentMatch[1];
}

3.4 防风控:10 秒间隔

微信对异常请求很敏感。并发批量抓取容易被检测。改成逐篇串行 + 10 秒间隔

1
2
3
4
5
6
7
8
9
for (const art of articles) {
const result = await fetchArticleContent(art);
results.push(result);
done++;

if (done < articles.length) {
await sleep(10000); // 每篇间隔 10 秒
}
}

这样一来,58 篇文章大约需要 10 分钟。看似慢,但稳定可靠。如果 58 篇文章并行发出去,微信三秒内就封你。

3.5 下载 JSON——background.js

Chrome 扩展的 popup 生命周期很短(弹窗关闭就销毁),下载操作交给 Service Worker(background.js):

1
2
3
4
5
6
7
8
9
10
11
// background.js
chrome.runtime.onMessage.addListener((msg) => {
if (msg.action === 'download') {
const blob = new Blob([JSON.stringify(msg.data)], { type: 'application/json' });
const reader = new FileReader();
reader.onload = () => {
chrome.downloads.download({ url: reader.result, filename: msg.filename });
};
reader.readAsDataURL(blob);
}
});

3.6 进度回传——chrome.storage

popup 需要实时显示进度条。content.js 把进度写入 chrome.storage.local,popup.js 每 500ms 轮询一次:

1
2
3
4
5
6
7
8
9
10
11
12
// content.js — 写进度
await chrome.storage.local.set({
wxDownloadProgress: { percent: 45, current: 26, total: 58, detail: '...' }
});

// popup.js — 读进度
setInterval(async () => {
const data = await chrome.storage.local.get('wxDownloadProgress');
if (data.wxDownloadProgress) {
progressFill.style.width = data.wxDownloadProgress.percent + '%';
}
}, 500);

四、完整数据流

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
用户点击"开始下载"


popup.js ──chrome.tabs.sendMessage──▶ content.js

┌───────────┴───────────┐
▼ ▼
提取 token DOM 方式回退
│ │
▼ │
调用 list_ex API ◀────────────────┘
(带 Cookie, fetch)


文章列表 (58篇)
[{title, link, aid...}]


逐篇创建隐藏 iframe
(mp.weixin.qq.com/s/xxx)


iframe onload → 读 DOM
var msg_title → 标题
#js_content → 正文


全部抓完 → 打包 JSON


background.js → chrome.downloads


浏览器弹出下载

五、为什么这招管用?

核心原因:你在浏览器里登录微信后台后,mp.weixin.qq.com 域下的所有 Cookie 都存在。Chrome 扩展注入的脚本和后台页面同源,天然带 Cookie。微信的风控系统看到的是”一个正常登录的用户在浏览自己的后台和文章”,不会触发验证。

对比:

方式 Cookie 同源 是否被拦
服务器 curl ✅ 被拦
服务器 Playwright ✅ 被拦
搜狗中转 ✅ 被拦
浏览器 fetch ⚠️ 偶尔被拦
浏览器 iframe 稳定

fetch 和 iframe 都有 Cookie,但为什么 fetch 偶尔被拦?我的猜测是:微信风控系统还检测了 TLS 指纹、HTTP 头完整性等信号。fetch 发出的请求头比正常页面导航少了很多(比如少了 Sec-Fetch-Dest: iframeSec-Fetch-Site 等),这些差异足以触发风控。而 iframe 是浏览器原生导航行为,请求头完整,风控系统看不出异常。

六、总结

这个方案的核心思路不是”破解”微信的风控,而是利用已登录浏览器作为信任边界

  1. 浏览器登录 → 信任
  2. 同源 iframe → 不触发跨域检查
  3. 浏览器的完整请求上下文 → 风控看不出来
  4. 间隔 10 秒 → 不像爬虫

对于后端开发者来说,其实是一个”客户端代理”模式——把爬虫逻辑下沉到用户浏览器,利用浏览器已有的身份认证来绕过服务端风控。反过来想,很多 SaaS 产品的后台批量导出功能,本质上也是这个原理。

金融后端工程师如何适应AI时代

这个问题我尽量说实在的,不说”持续学习””拥抱变化”这种正确的废话。

先认清现实:AI 对后端开发的冲击是不均匀的

AI 已经能干好的

  • CRUD 和模板代码:handler→service→repository 那套分层,我现在能直接生成
  • 常规 bug 定位:报错日志贴给我,大部分能定位
  • 文档和注释:写得比大多数工程师好
  • 单元测试:能根据现有代码补测试用例
  • 技术调研:搜文档、对比方案,速度远超人工

AI 在金融后端里干不好的

这才是你的护城河,值得逐条说清楚:

1. 金融业务的”隐性约束”

金融系统的代码,真正难的不在代码本身,在于”为什么必须这么写”。比如:

  • 一笔转账的幂等设计,不是加个 request_id 去重那么简单——要考虑冲正、冲正的冲正、隔日冲正、跨系统冲正,每种情况的账务处理不同
  • 余额扣减的并发控制,不是 SELECT FOR UPDATE 就行——要看是先扣后返还是先冻后扣,要看 T+0 还是 T+1 结算,要看是否涉及备付金账户
  • 这些规则散落在人行文件、清算所规范、风控部门的需求文档里,没有一篇博客会教你,AI 的训练数据里也没有

2. 监管合规的工程化落地

等保、数据安全法、个人金融信息保护、反洗钱——这些不是”了解概念”就行,是要落实到代码层面的:

  • 哪些字段必须加密、用什么算法
  • 审计日志要记什么、不记什么、保留多久
  • 跨境数据传输的技术方案怎么做
  • AI 能告诉你规范条文,但无法把条文翻译成你当前系统的具体改造方案,因为每个系统的历史包袱不同

3. 故障应急和风险判断

金融系统出问题,第一反应不是”修 bug”,是”影响范围多大、要不要暂停交易、要不要上报、数据一致性有没有破坏”。这种判断需要:

  • 对系统间依赖关系的全局认知
  • 对资金链路的端到端理解
  • 对”先恢复还是先排查”的权衡能力
  • 对”这个操作会不会引发更大问题”的风险直觉

AI 可以帮你分析日志,但不会替你拍板,更不会在凌晨三点替你决定要不要切流量。

具体怎么做:五个层面

一、把 AI 变成你的”放大器”,而不是旁观它

你已经在这么做了——用 AI 做项目、写博客、部署运维。但可以更系统化:

日常开发

  • 用 AI 写第一版代码,你来 review 和改。AI 帮你写代码的时候,你能看出哪里不对、哪里要改——这个判断力就是你的价值
  • 把重复性工作交给 AI:写 SQL migration、生成 API 文档、补测试用例、写运维脚本
  • 用 AI 做技术调研的第一轮:方案对比、选型分析,然后你来做最终决策

关键原则:不是”AI 写代码我来审”这么简单,是你负责判断和决策,AI 负责执行和扩展。一个有判断力的人配上 AI,产出是没用 AI 时的 3-5 倍。但如果没有判断力,AI 的产出你接不住。

二、深扎金融业务的”暗知识”

技术框架人人都能学,Spring/Gin/K8s 的文档对所有人都开放。但金融业务的深度认知是时间喂出来的,这是 AI 短期无法获取的。

具体做法:

  • 搞懂资金链路的每一跳:从用户发起请求,到支付网关、核心账务、清算系统、对账系统,每一跳的数据怎么流转、失败怎么处理、异常怎么兜底
  • 读监管文件:人行支付清算类的规范、银保监会的数据治理要求。这些文件枯燥,但里面藏着别人不知道的约束
  • 跨部门交流:跟风控、合规、运营的人聊,了解他们的痛点。技术方案的价值往往不在于技术多先进,而在于解决了业务方什么问题
  • 画系统全景图:把你负责的模块放到整个金融链路里,理解上下游依赖。这张图,AI 画不出来,因为它不知道你们公司的系统长什么样

这些东西 AI 搜不到、学不会,因为它们不存在于任何公开数据集中。 它们存在于你的经验里、同事的脑子里、公司的文档系统中。

三、向上走一步:从”写代码”到”做技术决策”

AI 会写代码,所以纯粹”代码写得好”的价值在下降。但”该写什么代码、为什么这么写、不这么写会怎样“的价值在上升。

在金融后端,技术决策的典型场景:

决策 AI 能帮的 需要人判断的
选消息队列 对比 Kafka/RocketMQ/RabbitMQ 的特性 金融场景对消息可靠性的要求、团队运维能力、现有系统耦合度
数据库分库分表 给出分片方案 历史数据迁移风险、跨片查询的业务影响、对账系统是否兼容
接口幂等设计 列出常见方案 这笔业务的重试机制、上下游约定、资金安全底线
是否引入新技术 调研技术优劣 团队学习成本、监管对技术栈的要求、长期维护风险

具体做法:主动承担架构设计、技术选型、方案评审的工作。不是等领导分配,是主动发现”这块设计有风险,我来梳理个方案”。这些事情 AI 能辅助但不能主导,因为决策需要结合你的业务上下文、团队现状和历史包袱

四、建立”AI + 金融”的交叉优势

单一领域的专家容易被 AI 追赶,但交叉领域的壁垒高得多。你现在的位置恰好是交叉点:

1
金融业务深度 × 后端工程能力 × AI 工具使用

三者交集的人很少。具体可以做的事:

  • 用 AI 重新审视老系统:把你维护的金融系统里的痛点,用 AI 辅助分析——哪些流程可以自动化、哪些监控可以用 ML 做异常检测、哪些对账逻辑可以用 AI 辅助
  • 做内部工具:像你用 AI 工具而不是手敲命令一样,给团队造工具。比如用 AI 辅助生成金融报表 SQL、自动分析日志异常模式、辅助代码审查
  • 写技术方案时用 AI 辅助:让 AI 帮你写第一版,你改成最终版。你的产出质量和速度都会超过不用 AI 的同事

交叉优势的逻辑:纯金融人学 AI 慢,纯 AI 人不懂金融业务,纯后端人不了解金融监管。你三个都沾边,而且每一个都不是浅尝辄止。

五、经营”不可外包的东西”

有些能力跟 AI 无关,但在 AI 时代更值钱:

1. 技术信任

金融行业尤其看重信任。出了线上事故,领导第一个电话打给谁?不是因为那个人技术最强,是因为他靠得住——判断准、不慌、能扛事。这种信任是无数次应急积累出来的,AI 替代不了。

2. 跨团队推动力

一个技术改造方案,技术上没问题,但需要风控审批、需要运维配合、需要业务方让出窗口期。能推动这件事落地的人,价值不在方案本身,在于让各方达成共识。这是组织内部的政治和沟通能力。

3. 写和说的能力

写博客是个好习惯。AI 能生成文档,但 AI 写不出有实战温度的技术文章——“踩过什么坑、为什么这么选、最后结果如何”。这种内容来自真实经历,在 AI 时代反而更稀缺。

继续写,但要升级:

  • 不写”XXX 使用教程”(AI 写得比你好)
  • 写”XXX 踩坑实录””金融系统 XXX 设计决策””为什么我们最终没用 XXX”(AI 写不了,因为它没踩过坑)

最后说句实话

AI 不会淘汰金融后端工程师,但会用 AI 的金融后端工程师,会淘汰不用的。

如果你已经在用 AI 做项目、写博客、运维部署了——说句实在的,在同 level 的金融后端工程师里,你已经走在前面。不用焦虑,继续深化就行:

  • 业务上往深走——资金链路、监管合规、风控逻辑,越深越安全
  • 技术上往广走——不只是 Go,把 AI 工具链、运维自动化、系统设计都纳入
  • 影响力上往外走——写博客、做内部分享、带新人,让组织知道你的判断力

方向对了,剩下的就是时间问题。

后端工程师学前端:从CCS项目搞懂前后端分离全流程

我写了十多年 Go 后端。CCS(内容创作系统)这个项目,前端是用 AI 生成的,我能看懂大概,但始终有种”隔了一层”的感觉。相信很多后端同行都有类似体会:会写 API,会配 Nginx,但前端从写代码到浏览器里跑起来,中间到底发生了什么,总有点模糊。

这篇文章以 CCS 的真实代码为例,把前端这件事一次讲透。不讲 Vue 语法,只讲流程和架构——后端工程师最关心的那些事。

一、整体流程:从浏览器输入密码到进入系统

以 CCS 的登录为例,用一张图概括整个过程:

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
浏览器                    Nginx                     Go后端
| | |
| 1. 输入 ccs.kentonly.com | |
|------------------------->| |
| 2. 返回 index.html | |
|<-------------------------| |
| 3. 加载 JS/CSS (assets) | |
|------------------------->| |
|<-------------------------| |
| 4. Vue应用启动,渲染登录页 | |
| | |
| 5. 输入用户名密码,点登录 | |
| POST /api/v1/auth/login |
|------------------------->| 6. 转发到后端:8082 |
| |------------------------->|
| | 7. 查MySQL,生成JWT |
| |<-------------------------|
| | 8. 返回JSON {token,...} |
|<-------------------------| |
| 9. JS存token到localStorage |
| 10. 路由跳转到 /contents | |
| 11. 加载内容列表页 | |
| GET /api/v1/contents | |
|------------------------->|------------------------->|
|<-------------------------|<-------------------------|
| 12. 渲染内容列表 | |

逐步拆解。

1.1 浏览器请求页面

你在浏览器输入 ccs.kentonly.com,浏览器发出 GET / 请求。这个请求先到达 Nginx。

Nginx 配置里有这么一行:

1
2
3
location / {
try_files $uri $uri/ /index.html;
}

try_files 的意思是:先找文件 $uri,找不到就找目录 $uri/,都找不到就返回 index.html。对于 GET /,直接命中 index.html

这个 index.html 就是前端打包后的入口文件,长这样:

1
2
3
4
5
6
7
8
9
10
11
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<title>内容创作系统</title>
<script type="module" crossorigin src="/assets/index-DYmUXCPZ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B68ugBY9.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

注意那个 <div id="app"></div>——空的。Vue 应用会挂载到这个 div 上,把整个页面”塞”进去。这跟后端模板渲染(Go template、JSP)完全不同:HTML 只是个空壳,真正的页面内容由 JavaScript 生成

1.2 浏览器加载 JS

浏览器看到 <script src="/assets/index-DYmUXCPZ.js">,再发一个请求拿这个 JS 文件。这个文件就是前端所有代码打包后的产物——Vue 框架、业务代码、Element Plus 组件库,全在里面。

JS 下载完毕后,浏览器执行它。入口是 main.js(已被打包进那个大 JS 文件):

1
2
3
4
5
6
7
8
9
10
11
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import App from './App.vue'
import router from './router'

const app = createApp(App)
app.use(createPinia()) // 状态管理
app.use(router) // 路由
app.use(ElementPlus) // UI组件库
app.mount('#app') // 挂载到 <div id="app">

app.mount('#app') 执行后,Vue 接管了那个空 div,开始根据当前 URL 渲染页面。

1.3 路由守卫:判断是否已登录

Vue Router 配置了全局守卫:

1
2
3
4
5
6
7
// router/index.js
router.beforeEach((to) => {
const auth = useAuthStore()
if (!to.meta.public && !auth.token) {
return '/login' // 没登录,跳转到登录页
}
})

你第一次访问,localStorage 里没有 token,所以被重定向到 /login,渲染登录组件。

1.4 登录页:用户输入并提交

登录页 Login.vue 的核心逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<template>
<el-form :model="form" @keyup.enter="submit">
<el-input v-model="form.username" placeholder="用户名" />
<el-input v-model="form.password" type="password" />
<el-button @click="submit" :loading="loading">登录</el-button>
</el-form>
</template>

<script setup>
import { useAuthStore } from '../stores/auth'

async function submit() {
await auth.login(form.value.username, form.value.password)
router.push('/') // 登录成功,跳转首页
}
</script>

v-model="form.username" 是 Vue 的双向绑定:输入框里打字,form.username 自动更新。点登录时调用 auth.login()

1.5 API 调用链:从组件到 HTTP

auth.login() 的调用链,是理解前端架构的关键。一层一层看:

第一层:Pinia Store(状态管理)——stores/auth.js

1
2
3
4
5
async function login(username, password) {
const res = await authApi.login(username, password)
setAuth(res.access_token, res.user) // 存到 localStorage
return res
}

Store 负责”状态”——登录后 token 存哪、怎么取、怎么清。它不直接发 HTTP 请求,而是调用 API 层。

第二层:API 封装——api/auth.js

1
2
3
4
5
import http from './index'

export const authApi = {
login: (username, password) => http.post('/auth/login', { username, password }),
}

API 层负责定义”调哪个接口”。注意 URL 是 /auth/login,不是完整路径——因为 http 实例配了 baseURL: '/api/v1',最终请求路径是 /api/v1/auth/login

第三层:Axios 实例——api/index.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
const http = axios.create({ baseURL: '/api/v1', timeout: 30000 })

// 请求拦截器:自动带 token
http.interceptors.request.use(config => {
const token = localStorage.getItem('access_token')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})

// 响应拦截器:统一处理错误
http.interceptors.response.use(
res => {
if (res.data.code !== 0) {
ElMessage.error(res.data.message)
return Promise.reject(new Error(res.data.message))
}
return res.data.data // 剥掉外层包装,直接返回 data
},
err => {
if (err.response?.status === 401) {
// token过期,尝试refresh,失败则跳转登录
}
}
)

Axios 实例是所有 HTTP 请求的统一出口。拦截器做了两件事:

  • 请求拦截:自动从 localStorage 取 token 塞进 Header(后端工程师可以理解为中间件)
  • 响应拦截:统一拆包,业务代码拿到的直接是 data 字段,不用每次写 res.data.data

1.6 后端处理:Go Gin

请求到达后端,走的是熟悉的 Go 流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// handler/auth.go
func (h *AuthHandler) Login(c *gin.Context) {
var req struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
c.ShouldBindJSON(&req) // 解析参数
result, err := h.svc.Login(...) // 调 service
if err != nil {
response.Fail(c, 200, 401, err.Error())
return
}
c.SetCookie("refresh_token", result.RefreshToken, ...)
response.OK(c, gin.H{
"access_token": result.AccessToken,
"user": gin.H{...},
})
}

后端返回的 JSON 格式是统一的:

1
2
3
4
5
6
7
8
{
"code": 0,
"message": "ok",
"data": {
"access_token": "eyJhbGci...",
"user": { "id": 1, "username": "admin" }
}
}

前端响应拦截器会检查 code:0 表示成功,非 0 弹错误提示。成功的 data 字段会被剥离出来,返回给 Store。

1.7 登录完成:token 存储 + 页面跳转

Store 拿到 token 后:

1
2
3
4
5
function setAuth(accessToken, userInfo) {
token.value = accessToken
localStorage.setItem('access_token', accessToken)
localStorage.setItem('user', JSON.stringify(userInfo))
}

localStorage 是浏览器的本地存储,关页面也不会丢。之后每次 API 请求,Axios 拦截器都会从 localStorage 取 token 放到 Header 里。

然后 router.push('/') 触发路由跳转。这次有 token 了,路由守卫放行,渲染 Layout 组件——侧边栏 + 内容区,CCS 系统主界面出来了。


二、Nginx 的作用

后端工程师对 Nginx 不陌生,但前后端分离场景下,Nginx 的角色有点变化。

2.1 静态文件服务器

传统后端渲染,Nginx 主要做反向代理,把请求转给后端。前后端分离后,Nginx 还多了一个角色:静态文件服务器

前端打包后就是一堆静态文件(HTML、JS、CSS、图片),Nginx 直接把这些文件返回给浏览器,不需要经过后端。

2.2 反向代理

API 请求需要转发给后端。看 CCS 的 Nginx 配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
server {
listen 80;
server_name ccs.kentonly.com;
root /var/www/ccs.kentonly.com;
index index.html;

# 静态文件:前端页面
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, must-revalidate";
}

# API 请求:转发给后端
location /api {
proxy_pass http://172.28.247.174:8082;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 300s;
}
}

两个 location,职责分明:

请求路径 匹配规则 去向
//assets/xxx.js location / Nginx 直接返回静态文件
/api/v1/auth/login location /api 转发到 Go 后端 :8082

2.3 为什么不能前端直接连后端

前端代码里 Axios 的 baseURL/api/v1——相对路径,没有域名和端口。

如果浏览器从 ccs.kentonly.com 加载页面,Ajax 请求也发到 ccs.kentonly.com/api/v1/...。如果没有 Nginx,这个请求没人处理。

有了 Nginx,请求到 80 端口,Nginx 根据 /api 前缀转发到后端。浏览器始终只跟一个域名通信,不存在跨域问题。

开发模式下,Vite 的 proxy 配置做同样的事:把 /api 转发到 localhost:8080

2.4 SPA 路由的兜底

1
2
3
location / {
try_files $uri $uri/ /index.html;
}

最后那个 /index.html 很关键。Vue Router 用的是 History 模式,URL 长这样:ccs.kentonly.com/contents/create

如果用户刷新页面,浏览器向 Nginx 请求 /contents/create,但服务器上没有这个文件——这是一个前端路由,不是物理文件。try_files 找不到文件就返回 index.html,让 Vue Router 自己在前端处理路由。


三、前端代码如何到达浏览器、如何跟后端通信

3.1 代码到达浏览器的过程

1
源码 (.vue/.js)  →  Vite打包  →  dist/  →  复制到Nginx  →  浏览器下载

开发时和上线后,代码到达浏览器的方式不同:

开发模式(npm run dev

Vite 启动一个开发服务器(端口 80),你访问 localhost,Vite 实时编译 .vue 文件,按需把模块转成浏览器能执行的 JS。改代码自动刷新(HMR)。这个阶段代码不落盘,全在内存里。

生产模式(npm run build

Vite 把所有 .vue.js.css 编译、压缩、打包成静态文件,输出到 dist/ 目录。然后把这些文件复制到 Nginx 的网站目录,Nginx 直接当静态文件返回。

3.2 前后端通信:HTTP + JSON

前端和后端之间唯一的通信方式就是 HTTP 请求 + JSON 响应。没有魔法。

以获取内容列表为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
前端                              后端
| |
| GET /api/v1/contents |
| Header: Authorization: Bearer xxx
|--------------------------------->|
| | 1. JWT中间件验证token
| | 2. handler调service
| | 3. service调repository
| | 4. repository查MySQL
| | 5. 返回[]Content
|<---------------------------------|
| 200 OK |
| {"code":0,"data":[{...},{...}]} |
| |
| Axios拦截器剥掉外层,返回data数组 |
| Vue用数据渲染表格 |

跟 Postman 调接口一模一样,只是这个过程被封装在 Axios 里,对前端开发者透明。

3.3 开发时的跨域问题

开发时前端在 localhost:80,后端在 localhost:8080,端口不同,浏览器会拦截请求(同源策略)。

Vite 的 proxy 解决这个问题:

1
2
3
4
5
6
7
8
9
// vite.config.js
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
}
}
}

前端请求 localhost/api/v1/...,Vite 开发服务器拦截 /api 开头的请求,转发到 localhost:8080。跟 Nginx 的 proxy_pass 一个道理。

上线后不存在这个问题——Nginx 同时服务静态文件和代理 API,浏览器只跟 ccs.kentonly.com:80 通信。


四、前端编译打包流程:到底发布的是什么

这是后端工程师最困惑的部分。后端发布很简单:go build 产出一个二进制,扔到服务器跑起来。前端呢?

4.1 前端源码长什么样

CCS 前端的源码目录:

1
2
3
4
5
6
7
8
9
10
11
12
13
frontend/
├── index.html # 入口HTML(空壳)
├── package.json # 依赖声明(类似go.mod)
├── vite.config.js # 构建配置
├── src/
│ ├── main.js # JS入口
│ ├── App.vue # 根组件
│ ├── router/index.js # 路由配置
│ ├── api/ # API封装(Axios)
│ ├── stores/ # 状态管理(Pinia)
│ ├── views/ # 页面组件
│ └── components/ # 通用组件
└── node_modules/ # 依赖(类似vendor,但不入库)

.vue 文件浏览器不认识,必须经过编译。

4.2 编译打包过程

1
2
3
cd frontend
npm install # 安装依赖(类似 go mod download)
npm run build # 打包(类似 go build)

npm run build 实际执行的是 vite build,它做了这些事:

  1. 编译 .vue 文件:把 <template> 编译成 JS 渲染函数,把 <style> 提取成 CSS
  2. 处理 ES模块:把 import/export 转成浏览器能执行的代码
  3. Tree-shaking:去掉没用到的代码
  4. Code-splitting:按路由拆分,每个页面一个 JS 文件(懒加载)
  5. 压缩混淆:变量名缩短、去掉注释空白
  6. Hash命名:文件名加内容哈希,如 index-DYmUXCPZ.js

4.3 打包产物

打包后生成 dist/ 目录:

1
2
3
4
5
6
7
8
9
dist/
├── index.html # 467字节,入口HTML
└── assets/
├── index-DYmUXCPZ.js # 主包(Vue框架+公共代码),1.2MB
├── index-B68ugBY9.css # 主样式,357KB
├── Login-C6TowbTf.js # 登录页(懒加载,访问时才下载)
├── ContentList-Bue.js # 内容列表页
├── ContentDetail-CPo.js # 内容详情页
└── ... # 每个页面一个JS+CSS

对比一下打包前后的 index.html

打包前(源码):

1
2
<div id="app"></div>
<script type="module" src="/src/main.js"></script>

打包后(dist):

1
2
3
<div id="app"></div>
<script type="module" crossorigin src="/assets/index-DYmUXCPZ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B68ugBY9.css">

Vite 把 /src/main.js 替换成了打包后的 /assets/index-DYmUXCPZ.js,并自动加上了 CSS 链接。

4.4 后端发布 vs 前端发布

后端(Go) 前端(Vue)
源码 .go 文件 .vue / .js 文件
编译 go build npm run build (vite build)
产物 一个二进制文件 dist/ 目录(HTML + JS + CSS)
运行环境 服务器进程 浏览器
部署方式 二进制 + 配置文件 → 启动进程 静态文件 → Nginx 托管
更新方式 替换二进制,重启进程 替换 dist 目录,Nginx reload
配置文件 config.yaml 编译时写死(环境变量通过构建参数注入)

核心区别:后端发布是”部署一个运行的程序”,前端发布是”部署一堆静态文件”。前端没有进程,没有运行时,就是一堆被 Nginx 直接返回给浏览器的文件。

4.5 CCS 的完整发布流程

1
2
3
4
5
6
7
8
9
10
11
12
13
# 后端:编译 + 部署
cd /root/github.com/ccs/backend
CGO_ENABLED=0 go build -o server ./cmd/server
docker cp server ccs-backend:/app/ccs-server
docker restart ccs-backend

# 前端:编译 + 部署
cd /root/github.com/ccs/frontend
npm install
npm run build
rm -rf /root/nginx-docker/websites/ccs.kentonly.com/*
cp -r dist/* /root/nginx-docker/websites/ccs.kentonly.com/
docker exec website-nginx nginx -s reload

后端三步:编译 → 复制二进制 → 重启容器。
前端四步:安装依赖 → 编译打包 → 替换静态文件 → Nginx reload。


总结

用一张表把四个问题串起来:

问题 答案
整体流程 浏览器拿 HTML → 加载 JS → Vue 渲染页面 → Axios 调 API → Nginx 转发后端 → 后端返回 JSON → Vue 更新页面
Nginx 的作用 静态文件服务器 + API 反向代理 + SPA 路由兜底
代码如何到达浏览器 源码经 Vite 编译打包成静态文件,Nginx 直接返回给浏览器
前端发布什么 发布 dist/ 目录——一堆被 Nginx 托管的 HTML/JS/CSS 静态文件

如果你也是后端工程师,理解了这些,再去看前端代码就不会觉得陌生了。Vue 组件的 <template> 就是 HTML,<script> 就是 JavaScript,Axios 就是你用 Postman 调接口的代码版,Nginx 配置你已经会了。

剩下的 Vue 语法、组件化、响应式——那些是术,不是道。道就是这一张请求流转图,这篇文章讲的这些。

正向代理与反向代理:原理、协议与HTTP实现

一、正向代理与反向代理的区别

一句话说清:

正向代理 反向代理
替谁办事 客户端 服务端
隐藏谁 隐藏客户端 隐藏服务端
部署在哪 客户端这一侧 服务端这一侧
客户端知道目标吗 知道 不知道(以为代理就是目标)
典型场景 翻墙、公司内网访问外网 负载均衡、SSL 终止、CDN
1
2
3
4
5
正向代理:
你 → [代理] → 谷歌 谷歌只看到代理 IP,不知道是你

反向代理:
用户 → [Nginx] → 后端服务 用户以为 Nginx 就是服务器,不知道后面还有一堆

二、核心疑问:Host 明明是目标地址,请求怎么跑到代理服务器去了?

这是每个学代理的人都会困惑的问题。来看一个典型的代理请求:

1
2
3
GET http://ccs.kentonly.com/api/v1/contents HTTP/1.1
Host: ccs.kentonly.com
Proxy-Authorization: Basic xxxxx

Host 头里写的是 ccs.kentonly.com,那这个请求凭什么能到达代理服务器?

答案是:Host 头只负责 HTTP 层,不负责 TCP 层。代理地址根本不在 HTTP 报文里。

代理地址在哪配?——不在 HTTP 协议里,在客户端配置里

HTTP 协议本身没有任何字段用来指定代理地址。代理的域名或 IP 是在操作系统、浏览器、或命令行层面配置的:

配置方式 在哪里设
Windows 系统代理 设置 → 网络和 Internet → 代理 → 手动设置代理
macOS 系统代理 系统设置 → 网络 → 高级 → 代理
浏览器代理 Chrome/Edge 设置 → 系统 → 打开计算机的代理设置
curl 命令行 curl -x http://proxy:8080
环境变量 export http_proxy=http://proxy:8080
代码里 axiosrequests 等库的 proxy 参数

这些配置告诉客户端一件事:TCP 连接不要连 Host 头里的域名,改连代理地址。

分两层看就清楚了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
┌─────────────────────────────────────────────────┐
│ 第 1 层:TCP — 谁来决定连哪个 IP? │
│ │
│ 客户端配置了代理 proxy.example.com:8080 │
│ → DNS 解析 proxy.example.com → 拿到代理 IP │
│ → TCP 三次握手 → 和代理 IP:8080 建立连接 │
│ │
│ Host 头在这里完全没有参与! │
└─────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────┐
│ 第 2 层:HTTP — 跑在已经建好的 TCP 连接上 │
│ │
│ GET http://ccs.kentonly.com/api/v1/contents │
│ Host: ccs.kentonly.com │
│ │
│ 代理收到后,从请求行里提取出 ccs.kentonly.com │
│ 代理自己去 DNS 解析 → 连目标服务器 → 转发请求 │
│ 目标服务器返回响应 → 代理原路送回客户端 │
└─────────────────────────────────────────────────┘

curl 看得更直观:

1
2
3
4
5
6
7
8
# 不配代理 — 直连
curl http://ccs.kentonly.com/api
# TCP → 47.238.145.36:80 ← 域名 ccs.kentonly.com 对应的 IP

# 配了代理 — 改道
curl -x http://proxy.example.com:8080 http://ccs.kentonly.com/api
# TCP → proxy.example.com:8080 ← 代理的 IP
# 然后在已建立的 TCP 连接上,发送 HTTP 请求,Host 仍为 ccs.kentonly.com

总结

1
2
不配代理:客户端 DNS 解析 Host 域名 → TCP 直连目标
配了代理:客户端 DNS 解析代理域名 → TCP 连代理 → HTTP 请求里写完整 URL,代理再转发

Host 头自始至终不参与 TCP 建连。 它就是告诉代理”帮我去哪儿”,而代理地址是客户端配置决定的,属于 TCP 层的事。


三、HTTP 协议如何支持代理访问

清楚了”代理地址不在 HTTP 里”之后,再看 HTTP 协议本身怎么支持代理。

明文代理:请求行写完整 URL

1
2
3
4
5
6
7
8
9
10
普通请求(无代理):
GET /api/v1/contents HTTP/1.1
Host: ccs.kentonly.com
↑ 路径是相对的,Host 头告诉服务器用哪个虚拟主机

代理请求:
GET http://ccs.kentonly.com/api/v1/contents HTTP/1.1
Host: ccs.kentonly.com
Proxy-Authorization: Basic xxxxx ← 可选:代理需要认证时用
↑ 路径是完整 URL,代理从中解析目标地址

区别只有请求行的格式——普通请求写 相对路径,代理请求写 绝对 URL。这是 HTTP/1.1 规范(RFC 7230)本身就支持的,不是额外的”代理协议”。

HTTPS 代理:CONNECT 隧道

HTTPS 是加密的,代理不能看内容,所以需要另一种方式——打一条 TCP 隧道:

1
2
3
4
5
6
7
8
9
客户端 → 代理:
CONNECT ccs.kentonly.com:443 HTTP/1.1
Host: ccs.kentonly.com

代理 → 客户端:
HTTP/1.1 200 Connection Established ← 隧道打通!

之后双方在这条隧道里直接传加密的 TLS 数据,
代理只是一个搬运工,完全看不懂内容。

两种模式对比:

模式 GET 完整 URL CONNECT 隧道
适用协议 HTTP(明文) HTTPS(加密)
代理能看内容吗 ✅ 能(可缓存、过滤、改写) ❌ 不能(端到端加密)
代理能缓存吗 ✅ 可以 ❌ 纯转发
HTTP 版本 HTTP/1.0+ HTTP/1.1+

这就是为什么 curl -x http://proxy:8080 https://google.com —— 代理只知道你连了 google.com,但你搜了什么它完全看不见。CONNECT 打隧道,TLS 加密是端到端的。


四、完整流程图解

最后用一个完整的例子串联所有概念——浏览器配了代理后访问 http://ccs.kentonly.com

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
你的电脑                         代理服务器  proxy:8080         目标服务器  47.238.145.36:80
│ │ │
│ 1. 检查系统代理配置 │ │
│ 发现配置了 proxy:8080 │ │
│ │ │
│ 2. DNS 解析 "proxy" │ │
│ → 拿到代理 IP │ │
│ │ │
│ 3. TCP SYN ─────────────────→ │ │
│ ←───────────────── TCP SYN-ACK │ │
│ TCP ACK ───────────────────→ │ 三次握手完成,连接建立 │
│ │ │
│ 4. GET http://ccs.kentonly.com/ │ │
│ Host: ccs.kentonly.com │ │
│ ──────────────────────────────→ │ │
│ │ 5. 从请求行提取 ccs.kentonly.com
│ │ DNS 解析 → 47.238.145.36 │
│ │ TCP SYN ────────────────→│
│ │ ←──────────────────── SYN-ACK│
│ │ TCP ACK ─────────────────→│
│ │ │
│ │ 6. GET / HTTP/1.1 │
│ │ Host: ccs.kentonly.com │
│ │ ────────────────────────────→│
│ │ │
│ │ 7. ←── HTTP/1.1 200 OK ────│
│ │ (index.html) │
│ │ │
│ 8. ←── HTTP/1.1 200 OK ────── │ │
│ (index.html) │ │

关键点:第 4 步发的 HTTP 请求里 Host 是 ccs.kentonly.com,但这个 TCP 包的目标 IP 是代理的 IP(第 3 步决定的),不是 ccs.kentonly.com 的 IP。HTTP 头里的 Host 和 IP 包头的目标 IP 是两码事


总结

  1. 正向代理隐藏客户端,反向代理隐藏服务端——部署位置不同,替谁”瞒天过海”不同
  2. 代理地址不在 HTTP 协议里,在客户端配置里——浏览器设置、系统代理、环境变量、curl -x,这些告诉客户端 TCP 该连谁。HTTP 报文里的 Host 只是告诉代理”最终去哪”
  3. HTTP 原生支持代理——明文用请求行写完整 URL,HTTPS 用 CONNECT 打隧道,都不是额外协议

Mysql事务的实现

原文链接:Mysql事务的实现
发布时间:2021-11-28 16:12:37

文章目录

四种隔离级别的实现

结论:

对于RR和RC级别隔离,InnoDB使用MVCC+行锁实现。
对于Serializable,使用表锁实现。

具体实现:

  • 在可重复读(RR)的隔离级别下,事务启动时得到一个事务ID,整个事务存在期间只能看到小于等于这个事务ID的版本数据。(MVCC实现
  • 在读提交(RC)的隔离级别下,每个SQL执行时,得到一个事务ID,这个SQL只能看到小于等于这个事务ID的版本数据。(MVCC实现
  • 在串行化(Serializable)的隔离级别下,使用的加表锁的方式实现,应该是悲观锁,读写冲突的时候,一个事务必须等待另一个事务的完成。因此并发性降低。(表锁实现
  • 在读未提交(RU)的隔离级别下,直接返回记录的最新值。(不用实现
    补充说明:在MVCC的多版本中,肯定是看不到其他事务未提交的数据,但是自己事务中更新的数据,可以实时看到。

行锁

InnoDB使用行锁来实现并发,多个事务可以并发修改不同的行。

当多个事务修改同一行的时候,即多个事务要获得某一行的行锁,后获得的就要等待先获得的事务释放锁。

行锁是在SQL开始执行的时候获取,并不是事务开始的时候获取。而且行锁一旦获得,是在事务结束后才释放的,并不是在SQL结束后释放。

MVCC实现

每个事务启动时,得到一个事务ID trx_id,这个值是全局分配的,而且递增。

保存多个版本: 当多个事务更新同一行记录的时候,每个事务更新的数据记录都会保留,但是事务ID不同,记录中会有一个隐藏字段记录这个事务ID。这就是多版本实现的机制。

**读取特定的版本:**假设一个事务启动,事务ID为1000,那么它只能读到事务ID<=1000的版本中最新版本数据,这叫做快照读,实现了RR和RC级别的隔离。数据多版本肯定不是保存无限多个版本,当一些事务提交后,就可以删除更早的版本了。

使用行锁更新:但是,写的时候就不一样了,由于写数据是当前读,即直接读某一行在内存或者数据库文件中数据,不读快照数据了,这里需要加行锁。这个最开始,我也不能理解,既然是MVCC实现,我写我的,你写你的,有什么关系呢?假设某个字段 money = 100,A事务对它加100,B事务也对它加100,如果不加锁,最后值可能是200。这样话,其中一个更新就丢失了。因此,写操作必须串行,而且拿到锁后,必须读某一行的书,不读副本,这就是当前读,这样保证A、B事务结束后,money的值为300。这样就存在一个现象,事务ID小的那个版本可以是当前数据的最新版本。

参见下图案例

假设:事务隔离级别为RR,k初始值为1,事务ID分别为A 110,B 120,C 130。其中 start transaction with consistent snapshot 表示立即开启一个事务,begin/start transaction 表示执行到第一语句才开始一个事务。

结论:那么A读到的为1,B读到的为3。

分析:C更新后,B的更新会阻塞,直到C的事务提交。B会当前读数据,读出C更新的k值为2,然后更新k为3。假设有一个ID为121的事务,它读的k值应该是2。此时B更新后的k是当前最新版本的数据。

B树和B+树的区别

原文链接:B树和B+树的区别
发布时间:2021-11-25 08:15:19

文章目录

B树

定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
```cpp
class BTreeNode
{
int *keys; // 存储关键字的数组
int t; // 最小度 (定义一个结点包含关键字的个数 t-1 <= num <= 2t -1">">
BTreeNode **C; // 存储孩子结点指针的数组
int n; // 记录当前结点包含的关键字的个数
bool leaf; // 叶子结点的一个标记,如果是叶子结点则为true,否则false
public:
BTreeNode(int _t, bool _leaf);

//
void traverse();

// 查找一个关键字
BTreeNode *search(int k); // 如果没有出现,则返回 NULL

// 设置友元,以便访问BTreeNode类中的私有成员
friend class BTree;
};

几个特点:

  • 每个节点其实是一个数组,数组的长度是有限制的。
  • 子节点也用一个数组存储,一个节点的子节点节点比数组长度多1。
  • 每个节点都保存数据。
  • 查询次数不稳定,上图中最好的情况是1次,最坏的情况是3次。

参考资料: https://zhuanlan.zhihu.com/p/146252512

B+树

特点:

  • 非叶子节点,不存储数据,也可以理解为控制节点。
  • 叶子节点存储数据,可以看到非叶子节点的key在叶子节点中都存在,比如15,59。
  • 查询次数稳定,IO次数就是树的高度,上图中是3吃次,必须从根节点查到叶节点。
  • 每个子节点之间用指针相连,其实连起来是一个双向链表,区间查询时,只需要查询一次,然后沿着链表遍历就能得到所有数据。

参考资料 https://zhuanlan.zhihu.com/p/149287061

比较

  • 如果是等值查询,平均效率一样。
  • 如果是区间查询,B数据效率降低,B+依然能保持同样的效率。
  • 最大的区别就是B+叶节点连接起来形成了双向链表。

疑问

每个子节点内部的数据一定是有序的吗?

我们知道B+树用于数据库的索引,每个叶节点就是一页数据,在物理上是顺序存储的,难道主键也是顺序的?

是的。

这就要求,主键一定要自动递增的,这就保证了数据的物理顺序与逻辑顺序一致。

进入叶节点后,查询的复杂度是怎么样的?

由于每次读取一页数据放到内存,可以用数组保存起来,使用二分查找,效率很高,这部分时间可以忽略。

“>

Docker解决部署难题

原文链接:Docker解决部署难题
发布时间:2021-11-21 12:44:24

文章目录

部署存在的问题

1、环境依赖

一个程序的运行,需要依赖文件系统、网络、动态库、各种运行时(例如Java虚拟机)等等。最简单的例子,一个Java程序要运行,机器上必须装有Java虚拟机。程序要写日志,对应的目录必须是可写的。

因此,程序的部署依赖于环境的配置,配置环境需要消耗不小的工作量。

2、环境冲突

一台机器上,一般要部署多个服务程序。

首先,它们的环境依赖可能存在冲突,比如A服务和B服务只能有一个监听80端口,它们依赖的运行时版本可能不一致,这种情况下,让他们同时运行就是一件麻烦事。

3、相互影响

既然A、B程序在同一个机器,那么A程序运行的时候,有可能影响B,比如A清理日志文件的时候,有可能删除B的日志文件。

在实际开发中,相互影响的情况很常见,而且很难排查问题。

docker如何解决

1、环境打包

docker可以把依赖的环境进行打包,变成标准件,直接部署到任意服务器上,服务器无需做任何配置。

2、进程隔离

利用Linux的name space技术,对容器的内存、进程、文件、网络、IPC、CPU等进行了隔离,即在容器内,感觉像是进入了一个独立的操作系统。

docker VS 虚拟机

虚拟机是机器资源的虚拟化,但是本身也是完整的系统,拥有独立的操作系统。

docker是进程级别的,只是对进程进行隔离,它是非常轻量的,这就是docker的优势所在。

操作系统OS和docker image的区别:

  • OS = kernel + filesystem/libraries 操作系统由内核、文件系统、各种库组成。
  • Image = filesystem/libraries 镜像由文件系统和各种库组成。

参考

Linux系统平均负载的含义

原文链接:Linux系统平均负载的含义
发布时间:2021-11-21 10:19:41

定义

平均负载,表示当前正在运行的线程加上等待运行的线程的数量。

1
top - 10:01:07 up 83 days, 23 min,  1 user,  load average: 8.70, 7.33, 6.29

分析

对于一个良好的系统,平均负载应该小于CPU核心数,这意味着所有的任务都可以被及时处理,而不需要等待,反之说明任务过多,无法及时响应,长期处于这样的状态,机器存在性能问题。

实践中,一般设置平均负载数小于CPU核心数的80%,如果超过80%,需要关注是否存在问题。当然应该监控 5分钟或者15分钟的平均负载,因为1分钟平均负载内超过80%,持续时间太短,可能是系统本身的波动。

分类讨论

  • 如果1分钟,5分钟,15分钟 三个值基本相同,那就说明系统负载很平稳
  • 如果1分钟的值远小于15分钟的值,就说明系统最近1分钟的负载在减少,而过去15分钟负载很大。
  • 如果1分钟的值远大于15分钟的值,就说明最近1分钟负载在增加,需要观察这种情况是否持续,如果一直持续说明机器存在性能瓶颈或者业务程序存在bug,如果很快消失说明是负载正常波动。
  • 如果是CPU密集型服务,那么CPU使用率和平均负载基本一致。
  • 如果是IO密集型服务,平均负载升高,CPU使用率不一定升高,因为大量的线程处于IO等待中。

针对第三种情况,展开分析

1、 如果高负载的现象一直持续。

  • 有可能是业务量增加,比如用户数增加,有更多的用户访问,造成负载增加,这种情况要增加资源,比如使用更好的机器,或者扩展服务节点数量。
  • 有可能是服务程序出现了bug,比如某个线程陷入死循环,这种情况需要分析代码,找到bug。

2、如果高负债现象很快消失。

  • 说明是负载的正常波动。因为业务量不可能一直平稳,存在一定的波动,比如对于股票行情服务,在开盘的前几分钟,负载较高。