<?xml version="1.0" encoding="UTF-8"?><?xml-stylesheet href="/scripts/pretty-feed-v3.xsl" type="text/xsl"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:h="http://www.w3.org/TR/html4/"><channel><title>星河避难所</title><description>写代码，也写自己</description><link>https://hejunjie.life</link><item><title>一次被封 IP 之后：从 Shadowsocks 到 Xray Reality</title><link>https://hejunjie.life/blog/jaai7bt0</link><guid isPermaLink="true">https://hejunjie.life/blog/jaai7bt0</guid><description>记录一次服务器 IP 被封后的折腾过程，从早期的 Shadowsocks 到基于 Xray 的 VLESS + Reality 方案，整理了服务端安装、配置文件结构以及 Clash 客户端规则</description><pubDate>Mon, 09 Mar 2026 07:17:12 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;⚠️ 本文仅为个人技术研究记录，用于学习网络协议与服务器部署，请遵守当地法律法规。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;我之前一直用的是 Shadowsocks，配置简单、部署也很轻量。一台服务器开个端口基本就能跑起来，用了很长一段时间都没什么问题。&lt;/p&gt;
&lt;p&gt;原本的文章链接在这里，密码是一样的：&lt;a href=&quot;/blog/hqnd9ut2&quot;&gt;点击前往&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;直到有一天，我发现它突然连不上了。&lt;/p&gt;
&lt;p&gt;服务器本身是正常的，SSH 也能登录，国外访问也没问题，但国内连接就是不通。简单排查了一圈之后，基本可以确定是 &lt;strong&gt;IP 被封&lt;/strong&gt; 了。&lt;/p&gt;
&lt;p&gt;这种情况其实挺常见的，尤其是长期使用单一协议的时候。&lt;/p&gt;
&lt;p&gt;幸好云服务器可以通过弹性IP去切换IP，于是我干脆把这次当成一个契机，重新研究了一下现在主流的一些方案，最后选择了基于 Xray 的 &lt;strong&gt;VLESS + Reality&lt;/strong&gt; 组合。&lt;/p&gt;
&lt;p&gt;这篇文章就简单记录一下整个折腾过程。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;安装 Xray&lt;/h2&gt;
&lt;p&gt;官方提供了一个非常方便的安装脚本，仓库在这里：&lt;a href=&quot;https://github.com/XTLS/Xray-install&quot;&gt;https://github.com/XTLS/Xray-install&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;只需要执行脚本里的 &lt;code&gt;install-release.sh&lt;/code&gt; 就可以完成安装。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;bash install-release.sh
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;安装完成之后，一般会生成两个主要文件：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;/usr/local/bin/xray
/usr/local/etc/xray/config.json
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;其中：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;​&lt;code&gt;/usr/local/bin/xray&lt;/code&gt; 是程序本体&lt;/li&gt;
&lt;li&gt;​&lt;code&gt;/usr/local/etc/xray/config.json&lt;/code&gt; 是默认配置文件&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;后续我们主要就是修改这个配置文件。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;生成 UUID&lt;/h2&gt;
&lt;p&gt;在使用 VLESS 的时候，需要一个 &lt;strong&gt;UUID&lt;/strong&gt; 作为客户端身份标识。&lt;/p&gt;
&lt;p&gt;最简单的方式是直接在服务器上生成：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cat /proc/sys/kernel/random/uuid
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;输出类似这样：&lt;code&gt;c9a1e0f2-xxxx-xxxx-xxxx-xxxxxxxxxxxx&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;只要是标准 UUID 格式就可以，用在线工具生成其实也没问题。&lt;/p&gt;
&lt;p&gt;这个 UUID 后面会写进配置文件里，同时客户端也需要使用同一个值。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;生成密钥对&lt;/h2&gt;
&lt;p&gt;Reality 需要使用一对 ​&lt;strong&gt;X25519 密钥&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;可以直接用 Xray 自带的命令生成：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;xray x25519
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;会得到类似这样的输出：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;PrivateKey: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
PublicKey:  xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Hash32:     xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;其中：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;​&lt;strong&gt;PrivateKey&lt;/strong&gt;：服务端使用&lt;/li&gt;
&lt;li&gt;​&lt;strong&gt;PublicKey&lt;/strong&gt;：客户端使用&lt;/li&gt;
&lt;li&gt;​&lt;strong&gt;Hash32&lt;/strong&gt;：用于校验&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;简单理解就是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;服务端持有私钥&lt;/li&gt;
&lt;li&gt;客户端使用公钥进行连接&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;完善配置文件&lt;/h2&gt;
&lt;p&gt;接下来需要修改 Xray 的配置文件：&lt;code&gt;/usr/local/etc/xray/config.json&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;在编写配置之前，需要确保服务器端口是开放的，没有被防火墙拦截。可以通过下面的方式简单检查：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;nc -vz 服务器IP 端口号
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果端口是通的，一般会看到类似：&lt;code&gt;Connection to xxx.xxx.xxx.xxx port xxxx [tcp/*] succeeded!&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;配置文件本身其实就是一份 JSON 结构，大致包含几个部分：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;​&lt;strong&gt;inbounds&lt;/strong&gt;：客户端入口&lt;/li&gt;
&lt;li&gt;​&lt;strong&gt;outbounds&lt;/strong&gt;：流量出口&lt;/li&gt;
&lt;li&gt;​&lt;strong&gt;routing&lt;/strong&gt;：路由规则&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;realitySettings&lt;/strong&gt;：Reality 相关配置&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;个人自用的完整配置模板如下：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yml&quot;&gt;{
  &apos;log&apos;: { &apos;loglevel&apos;: &apos;debug&apos; },
  &apos;inbounds&apos;:
    [
      {
        &apos;port&apos;: 服务器端口,
        &apos;protocol&apos;: &apos;vless&apos;,
        &apos;settings&apos;:
          {
            &apos;clients&apos;: [{ &apos;id&apos;: &apos;生成的UUID&apos;, &apos;flow&apos;: &apos;xtls-rprx-vision&apos; }],
            &apos;decryption&apos;: &apos;none&apos;,
            &apos;fallbacks&apos;: [{ &apos;dest&apos;: 80 }]
          },
        &apos;streamSettings&apos;:
          {
            &apos;network&apos;: &apos;tcp&apos;,
            &apos;security&apos;: &apos;reality&apos;,
            &apos;realitySettings&apos;:
              {
                &apos;show&apos;: false,
                &apos;dest&apos;: &apos;www.cloudflare.com:443&apos;,
                &apos;xver&apos;: 0,
                &apos;serverNames&apos;: [&apos;www.cloudflare.com&apos;],
                &apos;privateKey&apos;: &apos;生成密钥对中的PrivateKey&apos;,
                &apos;shortIds&apos;:
                  [
                    &apos;0-16位字母或数字&apos;,
                    &apos;自定义标识,类似密码,可以设置多个&apos;,
                    &apos;客户端携带的信息与任意一个匹配则通过&apos;
                  ]
              }
          },
        &apos;sniffing&apos;: { &apos;enabled&apos;: true, &apos;destOverride&apos;: [&apos;http&apos;, &apos;tls&apos;] }
      }
    ],
  &apos;outbounds&apos;: [{ &apos;protocol&apos;: &apos;freedom&apos; }]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;启动 Xray&lt;/h2&gt;
&lt;p&gt;配置完成之后，就可以通过 systemd 管理 Xray 服务了。&lt;/p&gt;
&lt;p&gt;常用命令如下：&lt;/p&gt;
&lt;p&gt;启动服务：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;systemctl start xray
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;重启服务：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;systemctl restart xray
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;查看状态：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;systemctl status xray
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;查看实时日志：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;journalctl -u xray -f
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;设置开机自启：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;systemctl enable xray
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果配置有问题，日志里通常会直接报错，定位问题其实还算方便。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;通过 Clash 连接&lt;/h2&gt;
&lt;p&gt;客户端我使用的是 Clash。&lt;/p&gt;
&lt;p&gt;PC端我会选择 Clash Verge 「&lt;a href=&quot;https://github.com/clash-verge-rev/clash-verge-rev&quot;&gt;GitHub&lt;/a&gt;」「&lt;a href=&quot;https://www.clashverge.dev/&quot;&gt;官方网站&lt;/a&gt;」&lt;/p&gt;
&lt;p&gt;移动端我会选择 FlClash 「&lt;a href=&quot;https://github.com/chen08209/FlClash&quot;&gt;GitHub&lt;/a&gt;」&lt;/p&gt;
&lt;p&gt;Clash 的配置文件本质上是一个 YAML 文件，其中最核心的部分其实是 ​&lt;strong&gt;规则（rules）&lt;/strong&gt; 。&lt;/p&gt;
&lt;p&gt;规则的基本格式是：&lt;code&gt;匹配类型, 匹配内容, 处理方式&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;例如：&lt;code&gt;DOMAIN-SUFFIX,google.com,Proxy&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;意思是：所有以 &lt;code&gt;google.com&lt;/code&gt;​ 结尾的域名，都通过 &lt;code&gt;Proxy&lt;/code&gt; 这个代理组处理。&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;常见处理方式&lt;/h3&gt;
&lt;p&gt;规则匹配之后，需要决定如何处理流量。&lt;/p&gt;
&lt;p&gt;常见的处理方式包括：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;REJECT&lt;/strong&gt;: 拒绝访问。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;BLOCK&lt;/strong&gt;: 同样是拒绝访问。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;DIRECT&lt;/strong&gt;: 直接访问目标服务器，不经过代理。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;代理组名称&lt;/strong&gt;: 如果填写的是代理组名称，例如：&lt;code&gt;Proxy&lt;/code&gt;，就表示流量会交给 &lt;code&gt;proxy-groups&lt;/code&gt; 中定义的名为 &lt;code&gt;Proxy&lt;/code&gt; 的代理组处理。&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3&gt;常见匹配类型&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;DOMAIN&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;完全匹配域名&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;DOMAIN,google.com,Proxy
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;只有 &lt;code&gt;google.com&lt;/code&gt; 会匹配。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;DOMAIN-SUFFIX&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;后缀匹配&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;DOMAIN-SUFFIX,google.com,Proxy
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;例如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;www.google.com&lt;/li&gt;
&lt;li&gt;mail.google.com&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;都会匹配。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;DOMAIN-KEYWORD&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;关键字匹配&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;DOMAIN-KEYWORD,google,Proxy
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;只要域名里包含 &lt;code&gt;google&lt;/code&gt; 都会匹配，比如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;google.com&lt;/li&gt;
&lt;li&gt;googleapis.com&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;IP-CIDR&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;IP 段匹配&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;IP-CIDR,8.8.8.8/32,Proxy
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里 &lt;code&gt;/32&lt;/code&gt; 表示精确匹配单个 IP。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;GEOIP&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;按国家 IP 匹配&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;GEOIP,CN,DIRECT
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;表示中国大陆 IP 直接连接，不走代理。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;MATCH&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;默认规则&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;MATCH,Proxy
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果前面的规则都没有匹配，就使用这个。&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;示例配置&lt;/h3&gt;
&lt;p&gt;下面是我个人使用的一份配置结构示例（隐去了服务器信息）：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yml&quot;&gt;port: 7890
socks-port: 7891
redir-port: 7892
allow-lan: false
mode: Rule
log-level: info
external-controller: 127.0.0.1:9090
secret: &apos;&apos;
cfw-bypass:
  - localhost
  - 127.*
  - 10.*
  - 172.16.*
  - 172.17.*
  - 172.18.*
  - 172.19.*
  - 172.20.*
  - 172.21.*
  - 172.22.*
  - 172.23.*
  - 172.24.*
  - 172.25.*
  - 172.26.*
  - 172.27.*
  - 172.28.*
  - 172.29.*
  - 172.30.*
  - 172.31.*
  - 192.168.*
  - &amp;#x3C;local&gt;
cfw-latency-timeout: 3000
proxies:
  - name: 名称，我喜欢写我的服务器IP
    type: vless
    server: 我的服务器IP
    port: 服务端配置的端口
    uuid: 服务端配置的uuid
    network: tcp
    tls: true
    udp: false
    flow: xtls-rprx-vision
    servername: www.cloudflare.com
    client-fingerprint: chrome
    reality-opts:
      public-key: 服务端配置私钥对应的公钥
      short-id: 服务端配置的shortIds
proxy-groups:
  - name: 代理组名称
    type: select
    proxies:
      - proxies配置的名称
rules:
  - DOMAIN,hls.itunes.apple.com,代理组名称
  - DOMAIN,itunes.apple.com,代理组名称
  - DOMAIN,itunes.com,代理组名称
  - DOMAIN-SUFFIX,icloud.com,DIRECT
  - DOMAIN-SUFFIX,icloud-content.com,DIRECT
  - DOMAIN-SUFFIX,me.com,DIRECT
  - DOMAIN-SUFFIX,mzstatic.com,DIRECT
  - DOMAIN-SUFFIX,aaplimg.com,DIRECT
  - DOMAIN-SUFFIX,cdn-apple.com,DIRECT
  - DOMAIN-SUFFIX,apple.com,DIRECT
  ## 国内网站
  - DOMAIN-SUFFIX,akadns.net,DIRECT
  - DOMAIN-SUFFIX,akamaized.net,DIRECT
  - DOMAIN-SUFFIX,cn,DIRECT
  - DOMAIN-KEYWORD,-cn,DIRECT
  - DOMAIN-SUFFIX,126.com,DIRECT
  - DOMAIN-SUFFIX,126.net,DIRECT
  - DOMAIN-SUFFIX,127.net,DIRECT
  - DOMAIN-SUFFIX,163.com,DIRECT
  - DOMAIN-SUFFIX,360buyimg.com,DIRECT
  - DOMAIN-SUFFIX,36kr.com,DIRECT
  - DOMAIN-SUFFIX,acfun.tv,DIRECT
  - DOMAIN-SUFFIX,air-matters.com,DIRECT
  - DOMAIN-SUFFIX,aixifan.com,DIRECT
  - DOMAIN-KEYWORD,alicdn,DIRECT
  - DOMAIN-KEYWORD,alipay,DIRECT
  - DOMAIN-KEYWORD,taobao,DIRECT
  - DOMAIN-SUFFIX,amap.com,DIRECT
  - DOMAIN-SUFFIX,autonavi.com,DIRECT
  - DOMAIN-KEYWORD,baidu,DIRECT
  - DOMAIN-SUFFIX,bdimg.com,DIRECT
  - DOMAIN-SUFFIX,bdstatic.com,DIRECT
  - DOMAIN-SUFFIX,bilibili.com,DIRECT
  - DOMAIN-SUFFIX,caiyunapp.com,DIRECT
  - DOMAIN-SUFFIX,clouddn.com,DIRECT
  - DOMAIN-SUFFIX,cnbeta.com,DIRECT
  - DOMAIN-SUFFIX,cnbetacdn.com,DIRECT
  - DOMAIN-SUFFIX,cootekservice.com,DIRECT
  - DOMAIN-SUFFIX,csdn.net,DIRECT
  - DOMAIN-SUFFIX,ctrip.com,DIRECT
  - DOMAIN-SUFFIX,dgtle.com,DIRECT
  - DOMAIN-SUFFIX,dianping.com,DIRECT
  - DOMAIN-SUFFIX,douban.com,DIRECT
  - DOMAIN-SUFFIX,doubanio.com,DIRECT
  - DOMAIN-SUFFIX,duokan.com,DIRECT
  - DOMAIN-SUFFIX,easou.com,DIRECT
  - DOMAIN-SUFFIX,ele.me,DIRECT
  - DOMAIN-SUFFIX,feng.com,DIRECT
  - DOMAIN-SUFFIX,fir.im,DIRECT
  - DOMAIN-SUFFIX,frdic.com,DIRECT
  - DOMAIN-SUFFIX,g-cores.com,DIRECT
  - DOMAIN-SUFFIX,godic.net,DIRECT
  - DOMAIN-SUFFIX,gtimg.com,DIRECT
  - DOMAIN-SUFFIX,hongxiu.com,DIRECT
  - DOMAIN-SUFFIX,hxcdn.net,DIRECT
  - DOMAIN-SUFFIX,iciba.com,DIRECT
  - DOMAIN-SUFFIX,ifeng.com,DIRECT
  - DOMAIN-SUFFIX,ifengimg.com,DIRECT
  - DOMAIN-SUFFIX,ipip.net,DIRECT
  - DOMAIN-SUFFIX,iqiyi.com,DIRECT
  - DOMAIN-SUFFIX,jd.com,DIRECT
  - DOMAIN-SUFFIX,jianshu.com,DIRECT
  - DOMAIN-SUFFIX,knewone.com,DIRECT
  - DOMAIN-SUFFIX,le.com,DIRECT
  - DOMAIN-SUFFIX,lecloud.com,DIRECT
  - DOMAIN-SUFFIX,lemicp.com,DIRECT
  - DOMAIN-SUFFIX,licdn.com,DIRECT
  - DOMAIN-SUFFIX,luoo.net,DIRECT
  - DOMAIN-SUFFIX,meituan.com,DIRECT
  - DOMAIN-SUFFIX,meituan.net,DIRECT
  - DOMAIN-SUFFIX,mi.com,DIRECT
  - DOMAIN-SUFFIX,miaopai.com,DIRECT
  - DOMAIN-SUFFIX,microsoft.com,DIRECT
  - DOMAIN-SUFFIX,microsoftonline.com,DIRECT
  - DOMAIN-SUFFIX,miui.com,DIRECT
  - DOMAIN-SUFFIX,miwifi.com,DIRECT
  - DOMAIN-SUFFIX,mob.com,DIRECT
  - DOMAIN-SUFFIX,netease.com,DIRECT
  - DOMAIN-SUFFIX,office.com,DIRECT
  - DOMAIN-KEYWORD,officecdn,DIRECT
  - DOMAIN-SUFFIX,office365.com,DIRECT
  - DOMAIN-SUFFIX,oschina.net,DIRECT
  - DOMAIN-SUFFIX,ppsimg.com,DIRECT
  - DOMAIN-SUFFIX,pstatp.com,DIRECT
  - DOMAIN-SUFFIX,qcloud.com,DIRECT
  - DOMAIN-SUFFIX,qdaily.com,DIRECT
  - DOMAIN-SUFFIX,qdmm.com,DIRECT
  - DOMAIN-SUFFIX,qhimg.com,DIRECT
  - DOMAIN-SUFFIX,qhres.com,DIRECT
  - DOMAIN-SUFFIX,qidian.com,DIRECT
  - DOMAIN-SUFFIX,qihucdn.com,DIRECT
  - DOMAIN-SUFFIX,qiniu.com,DIRECT
  - DOMAIN-SUFFIX,qiniucdn.com,DIRECT
  - DOMAIN-SUFFIX,qiyipic.com,DIRECT
  - DOMAIN-SUFFIX,qq.com,DIRECT
  - DOMAIN-SUFFIX,qqurl.com,DIRECT
  - DOMAIN-SUFFIX,rarbg.to,DIRECT
  - DOMAIN-SUFFIX,ruguoapp.com,DIRECT
  - DOMAIN-SUFFIX,segmentfault.com,DIRECT
  - DOMAIN-SUFFIX,sinaapp.com,DIRECT
  - DOMAIN-SUFFIX,smzdm.com,DIRECT
  - DOMAIN-SUFFIX,sogou.com,DIRECT
  - DOMAIN-SUFFIX,sogoucdn.com,DIRECT
  - DOMAIN-SUFFIX,sohu.com,DIRECT
  - DOMAIN-SUFFIX,soku.com,DIRECT
  - DOMAIN-SUFFIX,speedtest.net,DIRECT
  - DOMAIN-SUFFIX,sspai.com,DIRECT
  - DOMAIN-SUFFIX,suning.com,DIRECT
  - DOMAIN-SUFFIX,taobao.com,DIRECT
  - DOMAIN-SUFFIX,tencent.com,DIRECT
  - DOMAIN-SUFFIX,tenpay.com,DIRECT
  - DOMAIN-SUFFIX,tianyancha.com,DIRECT
  - DOMAIN-SUFFIX,tmall.com,DIRECT
  - DOMAIN-SUFFIX,tudou.com,DIRECT
  - DOMAIN-SUFFIX,umetrip.com,DIRECT
  - DOMAIN-SUFFIX,upaiyun.com,DIRECT
  - DOMAIN-SUFFIX,upyun.com,DIRECT
  - DOMAIN-SUFFIX,veryzhun.com,DIRECT
  - DOMAIN-SUFFIX,weather.com,DIRECT
  - DOMAIN-SUFFIX,weibo.com,DIRECT
  - DOMAIN-SUFFIX,xiami.com,DIRECT
  - DOMAIN-SUFFIX,xiami.net,DIRECT
  - DOMAIN-SUFFIX,xiaomicp.com,DIRECT
  - DOMAIN-SUFFIX,ximalaya.com,DIRECT
  - DOMAIN-SUFFIX,xmcdn.com,DIRECT
  - DOMAIN-SUFFIX,xunlei.com,DIRECT
  - DOMAIN-SUFFIX,yhd.com,DIRECT
  - DOMAIN-SUFFIX,yihaodianimg.com,DIRECT
  - DOMAIN-SUFFIX,yinxiang.com,DIRECT
  - DOMAIN-SUFFIX,ykimg.com,DIRECT
  - DOMAIN-SUFFIX,youdao.com,DIRECT
  - DOMAIN-SUFFIX,youku.com,DIRECT
  - DOMAIN-SUFFIX,zealer.com,DIRECT
  - DOMAIN-SUFFIX,zhihu.com,DIRECT
  - DOMAIN-SUFFIX,zhimg.com,DIRECT
  - DOMAIN-SUFFIX,zimuzu.tv,DIRECT
  - DOMAIN-KEYWORD,netflix,代理组名称
  - DOMAIN-KEYWORD,nflx,代理组名称
  ## 抗 DNS 污染
  - DOMAIN-KEYWORD,amazon,代理组名称
  - DOMAIN-KEYWORD,google,代理组名称
  - DOMAIN-KEYWORD,gmail,代理组名称
  - DOMAIN-KEYWORD,youtube,代理组名称
  - DOMAIN-KEYWORD,facebook,代理组名称
  - DOMAIN-SUFFIX,fb.me,代理组名称
  - DOMAIN-SUFFIX,fbcdn.net,代理组名称
  - DOMAIN-KEYWORD,twitter,代理组名称
  - DOMAIN-KEYWORD,instagram,代理组名称
  - DOMAIN-KEYWORD,dropbox,代理组名称
  - DOMAIN-SUFFIX,twimg.com,代理组名称
  - DOMAIN-KEYWORD,blogspot,代理组名称
  - DOMAIN-SUFFIX,youtu.be,代理组名称
  - DOMAIN-KEYWORD,whatsapp,代理组名称
  - DOMAIN-KEYWORD,googleapis,代理组名称
  # Clubhouse
  - DOMAIN-SUFFIX,clubhouse.com,代理组名称
  - DOMAIN-SUFFIX,clubhouseapi.com,代理组名称
  - DOMAIN-SUFFIX,joinclubhouse.com,代理组名称
  - DOMAIN-SUFFIX,clubhouseprod.s3.amazonaws.com,代理组名称
  - DOMAIN-SUFFIX,clubhouse.pubnub.com,代理组名称

  - DOMAIN-SUFFIX, ap-oversea-tls.agora.io, DIRECT
  - DOMAIN-SUFFIX, ap-oversea.agora.io, DIRECT
  - DOMAIN-SUFFIX, ap-oversea2.agora.io, DIRECT
  - DOMAIN-SUFFIX, report-oversea.agora.io, DIRECT
  - IP-CIDR, 3.0.163.78/32, DIRECT
  - IP-CIDR, 13.230.60.35/32, DIRECT
  - IP-CIDR, 23.248.191.103/32, DIRECT
  - IP-CIDR, 23.248.191.105/32, DIRECT
  - IP-CIDR, 23.98.43.152/32, DIRECT
  - IP-CIDR, 35.178.208.187/32, DIRECT
  - IP-CIDR, 45.40.48.11/32, DIRECT
  - IP-CIDR, 45.255.124.98/32, DIRECT
  - IP-CIDR, 45.255.124.100/32, DIRECT
  - IP-CIDR, 45.255.124.101/32, DIRECT
  - IP-CIDR, 45.255.124.104/32, DIRECT
  - IP-CIDR, 45.255.124.105/32, DIRECT
  - IP-CIDR, 45.255.124.107/32, DIRECT
  - IP-CIDR, 45.255.124.108/32, DIRECT
  - IP-CIDR, 45.255.124.109/32, DIRECT
  - IP-CIDR, 45.255.124.135/32, DIRECT
  - IP-CIDR, 50.17.126.121/32, DIRECT
  - IP-CIDR, 52.52.84.170/32, DIRECT
  - IP-CIDR, 52.58.56.244/32, DIRECT
  - IP-CIDR, 52.194.158.59/32, DIRECT
  - IP-CIDR, 52.221.46.208/32, DIRECT
  - IP-CIDR, 54.178.26.110/32, DIRECT
  - IP-CIDR, 69.28.51.148/32, DIRECT
  - IP-CIDR, 103.59.49.10/32, DIRECT
  - IP-CIDR, 103.65.41.166/32, DIRECT
  - IP-CIDR, 103.65.41.169/32, DIRECT
  - IP-CIDR, 103.98.18.181/32, DIRECT
  - IP-CIDR, 103.98.18.183/32, DIRECT
  - IP-CIDR, 103.98.18.184/32, DIRECT
  - IP-CIDR, 103.98.18.189/32, DIRECT
  - IP-CIDR, 120.227.115.126/32, DIRECT
  - IP-CIDR, 122.10.255.165/32, DIRECT
  - IP-CIDR, 128.1.87.196/32, DIRECT
  - IP-CIDR, 129.227.71.203/32, DIRECT
  - IP-CIDR, 129.227.115.130/32, DIRECT
  - IP-CIDR, 148.153.126.146/32, DIRECT
  - IP-CIDR, 148.153.172.73/32, DIRECT
  - IP-CIDR, 148.153.172.74/32, DIRECT
  - IP-CIDR, 148.153.172.75/32, DIRECT
  - IP-CIDR, 148.153.172.76/32, DIRECT
  - IP-CIDR, 148.153.172.77/32, DIRECT
  - IP-CIDR, 164.52.0.244/32, DIRECT
  - IP-CIDR, 164.52.6.19/32, DIRECT
  - IP-CIDR, 164.52.6.21/32, DIRECT
  - IP-CIDR, 164.52.6.23/32, DIRECT
  - IP-CIDR, 164.52.6.24/32, DIRECT
  - IP-CIDR, 164.52.6.25/32, DIRECT
  - IP-CIDR, 164.52.32.57/32, DIRECT
  - IP-CIDR, 164.52.32.59/32, DIRECT
  - IP-CIDR, 164.52.32.60/32, DIRECT
  - IP-CIDR, 164.52.36.228/32, DIRECT
  - IP-CIDR, 164.52.36.232/32, DIRECT
  - IP-CIDR, 164.52.36.238/32, DIRECT
  - IP-CIDR, 164.52.36.243/32, DIRECT
  - IP-CIDR, 164.52.36.245/32, DIRECT
  - IP-CIDR, 164.52.36.254/32, DIRECT
  - IP-CIDR, 164.52.102.35/32, DIRECT
  - IP-CIDR, 164.52.102.66/32, DIRECT
  - IP-CIDR, 164.52.102.67/32, DIRECT
  - IP-CIDR, 164.52.102.68/32, DIRECT
  - IP-CIDR, 164.52.102.69/32, DIRECT
  - IP-CIDR, 164.52.102.70/32, DIRECT
  - IP-CIDR, 164.52.102.75/32, DIRECT
  - IP-CIDR, 164.52.102.76/32, DIRECT
  - IP-CIDR, 164.52.102.77/32, DIRECT
  - IP-CIDR, 164.52.102.91/32, DIRECT
  - IP-CIDR, 164.52.124.102/32, DIRECT
  - IP-CIDR, 199.190.44.36/32, DIRECT
  - IP-CIDR, 199.190.44.37/32, DIRECT
  - IP-CIDR, 202.181.136.106/32, DIRECT
  - IP-CIDR, 202.226.25.162/32, DIRECT
  - IP-CIDR, 202.226.25.166/32, DIRECT
  - IP-CIDR, 202.226.25.171/32, DIRECT
  - IP-CIDR, 202.226.25.195/32, DIRECT
  - IP-CIDR, 202.226.25.198/32, DIRECT
  - IP-CIDR, 129.227.57.143/32, DIRECT
  - IP-CIDR, 129.227.234.70/32, DIRECT
  - IP-CIDR, 129.227.234.82/32, DIRECT
  - IP-CIDR, 129.227.234.119/32, DIRECT
  - IP-CIDR, 129.227.71.144/32, DIRECT
  - IP-CIDR, 129.227.57.132/32, DIRECT
  - IP-CIDR, 129.227.57.134/32, DIRECT
  - IP-CIDR, 129.227.57.145/32, DIRECT
  - IP-CIDR, 129.227.71.141/32, DIRECT
  - IP-CIDR, 129.227.234.83/32, DIRECT
  - IP-CIDR, 129.227.71.142/32, DIRECT
  - IP-CIDR, 129.227.71.132/32, DIRECT
  - IP-CIDR, 129.227.71.133/32, DIRECT
  - IP-CIDR, 129.227.71.134/32, DIRECT
  - IP-CIDR, 129.227.234.67/32, DIRECT
  - IP-CIDR, 129.227.234.110/32, DIRECT
  - IP-CIDR, 129.227.234.112/32, DIRECT
  - IP-CIDR, 129.227.234.124/32, DIRECT
  - IP-CIDR, 129.227.71.140/32, DIRECT
  - IP-CIDR, 129.227.71.130/32, DIRECT
  - IP-CIDR, 129.227.71.131/32, DIRECT
  - IP-CIDR, 129.227.71.143/32, DIRECT
  - IP-CIDR, 129.227.156.17/32, DIRECT
  - IP-CIDR, 129.227.57.137/32, DIRECT
  - IP-CIDR, 129.227.156.20/32, DIRECT
  ## 国外网站
  - DOMAIN-SUFFIX,openai.com,代理组名称
  - DOMAIN-SUFFIX,9to5mac.com,代理组名称
  - DOMAIN-SUFFIX,abpchina.org,代理组名称
  - DOMAIN-SUFFIX,adblockplus.org,代理组名称
  - DOMAIN-SUFFIX,adobe.com,代理组名称
  - DOMAIN-SUFFIX,alfredapp.com,代理组名称
  - DOMAIN-SUFFIX,amplitude.com,代理组名称
  - DOMAIN-SUFFIX,ampproject.org,代理组名称
  - DOMAIN-SUFFIX,android.com,代理组名称
  - DOMAIN-SUFFIX,angularjs.org,代理组名称
  - DOMAIN-SUFFIX,aolcdn.com,代理组名称
  - DOMAIN-SUFFIX,apkpure.com,代理组名称
  - DOMAIN-SUFFIX,appledaily.com,代理组名称
  - DOMAIN-SUFFIX,appshopper.com,代理组名称
  - DOMAIN-SUFFIX,appspot.com,代理组名称
  - DOMAIN-SUFFIX,arcgis.com,代理组名称
  - DOMAIN-SUFFIX,archive.org,代理组名称
  - DOMAIN-SUFFIX,armorgames.com,代理组名称
  - DOMAIN-SUFFIX,aspnetcdn.com,代理组名称
  - DOMAIN-SUFFIX,att.com,代理组名称
  - DOMAIN-SUFFIX,awsstatic.com,代理组名称
  - DOMAIN-SUFFIX,azureedge.net,代理组名称
  - DOMAIN-SUFFIX,azurewebsites.net,代理组名称
  - DOMAIN-SUFFIX,bing.com,代理组名称
  - DOMAIN-SUFFIX,bintray.com,代理组名称
  - DOMAIN-SUFFIX,bit.com,代理组名称
  - DOMAIN-SUFFIX,bit.ly,代理组名称
  - DOMAIN-SUFFIX,bitbucket.org,代理组名称
  - DOMAIN-SUFFIX,bjango.com,代理组名称
  - DOMAIN-SUFFIX,bkrtx.com,代理组名称
  - DOMAIN-SUFFIX,blog.com,代理组名称
  - DOMAIN-SUFFIX,blogcdn.com,代理组名称
  - DOMAIN-SUFFIX,blogger.com,代理组名称
  - DOMAIN-SUFFIX,blogsmithmedia.com,代理组名称
  - DOMAIN-SUFFIX,blogspot.com,代理组名称
  - DOMAIN-SUFFIX,blogspot.hk,代理组名称
  - DOMAIN-SUFFIX,bloomberg.com,代理组名称
  - DOMAIN-SUFFIX,box.com,代理组名称
  - DOMAIN-SUFFIX,box.net,代理组名称
  - DOMAIN-SUFFIX,cachefly.net,代理组名称
  - DOMAIN-SUFFIX,chromium.org,代理组名称
  - DOMAIN-SUFFIX,cl.ly,代理组名称
  - DOMAIN-SUFFIX,cloudflare.com,代理组名称
  - DOMAIN-SUFFIX,cloudfront.net,代理组名称
  - DOMAIN-SUFFIX,cloudmagic.com,代理组名称
  - DOMAIN-SUFFIX,cmail19.com,代理组名称
  - DOMAIN-SUFFIX,cnet.com,代理组名称
  - DOMAIN-SUFFIX,cocoapods.org,代理组名称
  - DOMAIN-SUFFIX,comodoca.com,代理组名称
  - DOMAIN-SUFFIX,crashlytics.com,代理组名称
  - DOMAIN-SUFFIX,culturedcode.com,代理组名称
  - DOMAIN-SUFFIX,d.pr,代理组名称
  - DOMAIN-SUFFIX,danilo.to,代理组名称
  - DOMAIN-SUFFIX,dayone.me,代理组名称
  - DOMAIN-SUFFIX,db.tt,代理组名称
  - DOMAIN-SUFFIX,deskconnect.com,代理组名称
  - DOMAIN-SUFFIX,disq.us,代理组名称
  - DOMAIN-SUFFIX,disqus.com,代理组名称
  - DOMAIN-SUFFIX,disquscdn.com,代理组名称
  - DOMAIN-SUFFIX,dnsimple.com,代理组名称
  - DOMAIN-SUFFIX,docker.com,代理组名称
  - DOMAIN-SUFFIX,dribbble.com,代理组名称
  - DOMAIN-SUFFIX,droplr.com,代理组名称
  - DOMAIN-SUFFIX,duckduckgo.com,代理组名称
  - DOMAIN-SUFFIX,dueapp.com,代理组名称
  - DOMAIN-SUFFIX,dytt8.net,代理组名称
  - DOMAIN-SUFFIX,edgecastcdn.net,代理组名称
  - DOMAIN-SUFFIX,edgekey.net,代理组名称
  - DOMAIN-SUFFIX,edgesuite.net,代理组名称
  - DOMAIN-SUFFIX,engadget.com,代理组名称
  - DOMAIN-SUFFIX,entrust.net,代理组名称
  - DOMAIN-SUFFIX,eurekavpt.com,代理组名称
  - DOMAIN-SUFFIX,evernote.com,代理组名称
  - DOMAIN-SUFFIX,fabric.io,代理组名称
  - DOMAIN-SUFFIX,fast.com,代理组名称
  - DOMAIN-SUFFIX,fastly.net,代理组名称
  - DOMAIN-SUFFIX,fc2.com,代理组名称
  - DOMAIN-SUFFIX,feedburner.com,代理组名称
  - DOMAIN-SUFFIX,feedly.com,代理组名称
  - DOMAIN-SUFFIX,feedsportal.com,代理组名称
  - DOMAIN-SUFFIX,fiftythree.com,代理组名称
  - DOMAIN-SUFFIX,firebaseio.com,代理组名称
  - DOMAIN-SUFFIX,flexibits.com,代理组名称
  - DOMAIN-SUFFIX,flickr.com,代理组名称
  - DOMAIN-SUFFIX,flipboard.com,代理组名称
  - DOMAIN-SUFFIX,g.co,代理组名称
  - DOMAIN-SUFFIX,gabia.net,代理组名称
  - DOMAIN-SUFFIX,geni.us,代理组名称
  - DOMAIN-SUFFIX,gfx.ms,代理组名称
  - DOMAIN-SUFFIX,ggpht.com,代理组名称
  - DOMAIN-SUFFIX,ghostnoteapp.com,代理组名称
  - DOMAIN-SUFFIX,git.io,代理组名称
  - DOMAIN-KEYWORD,github,代理组名称
  - DOMAIN-KEYWORD,linkedin,代理组名称
  - DOMAIN-SUFFIX,globalsign.com,代理组名称
  - DOMAIN-SUFFIX,gmodules.com,代理组名称
  - DOMAIN-SUFFIX,godaddy.com,代理组名称
  - DOMAIN-SUFFIX,golang.org,代理组名称
  - DOMAIN-SUFFIX,gongm.in,代理组名称
  - DOMAIN-SUFFIX,goo.gl,代理组名称
  - DOMAIN-SUFFIX,goodreaders.com,代理组名称
  - DOMAIN-SUFFIX,goodreads.com,代理组名称
  - DOMAIN-SUFFIX,gravatar.com,代理组名称
  - DOMAIN-SUFFIX,gstatic.com,代理组名称
  - DOMAIN-SUFFIX,gvt0.com,代理组名称
  - DOMAIN-SUFFIX,hockeyapp.net,代理组名称
  - DOMAIN-SUFFIX,hotmail.com,代理组名称
  - DOMAIN-SUFFIX,icons8.com,代理组名称
  - DOMAIN-SUFFIX,ift.tt,代理组名称
  - DOMAIN-SUFFIX,ifttt.com,代理组名称
  - DOMAIN-SUFFIX,iherb.com,代理组名称
  - DOMAIN-SUFFIX,imageshack.us,代理组名称
  - DOMAIN-SUFFIX,img.ly,代理组名称
  - DOMAIN-SUFFIX,imgur.com,代理组名称
  - DOMAIN-SUFFIX,imore.com,代理组名称
  - DOMAIN-SUFFIX,instapaper.com,代理组名称
  - DOMAIN-SUFFIX,ipn.li,代理组名称
  - DOMAIN-SUFFIX,is.gd,代理组名称
  - DOMAIN-SUFFIX,issuu.com,代理组名称
  - DOMAIN-SUFFIX,itgonglun.com,代理组名称
  - DOMAIN-SUFFIX,itun.es,代理组名称
  - DOMAIN-SUFFIX,ixquick.com,代理组名称
  - DOMAIN-SUFFIX,j.mp,代理组名称
  - DOMAIN-SUFFIX,js.revsci.net,代理组名称
  - DOMAIN-SUFFIX,jshint.com,代理组名称
  - DOMAIN-SUFFIX,jtvnw.net,代理组名称
  - DOMAIN-SUFFIX,justgetflux.com,代理组名称
  - DOMAIN-SUFFIX,kat.cr,代理组名称
  - DOMAIN-SUFFIX,klip.me,代理组名称
  - DOMAIN-SUFFIX,libsyn.com,代理组名称
  - DOMAIN-SUFFIX,linode.com,代理组名称
  - DOMAIN-SUFFIX,lithium.com,代理组名称
  - DOMAIN-SUFFIX,littlehj.com,代理组名称
  - DOMAIN-SUFFIX,live.com,代理组名称
  - DOMAIN-SUFFIX,live.net,代理组名称
  - DOMAIN-SUFFIX,livefilestore.com,代理组名称
  - DOMAIN-SUFFIX,llnwd.net,代理组名称
  - DOMAIN-SUFFIX,macid.co,代理组名称
  - DOMAIN-SUFFIX,macromedia.com,代理组名称
  - DOMAIN-SUFFIX,macrumors.com,代理组名称
  - DOMAIN-SUFFIX,mashable.com,代理组名称
  - DOMAIN-SUFFIX,mathjax.org,代理组名称
  - DOMAIN-SUFFIX,medium.com,代理组名称
  - DOMAIN-SUFFIX,mega.co.nz,代理组名称
  - DOMAIN-SUFFIX,mega.nz,代理组名称
  - DOMAIN-SUFFIX,megaupload.com,代理组名称
  - DOMAIN-SUFFIX,microsofttranslator.com,代理组名称
  - DOMAIN-SUFFIX,mindnode.com,代理组名称
  - DOMAIN-SUFFIX,mobile01.com,代理组名称
  - DOMAIN-SUFFIX,modmyi.com,代理组名称
  - DOMAIN-SUFFIX,msedge.net,代理组名称
  - DOMAIN-SUFFIX,myfontastic.com,代理组名称
  - DOMAIN-SUFFIX,name.com,代理组名称
  - DOMAIN-SUFFIX,nextmedia.com,代理组名称
  - DOMAIN-SUFFIX,nsstatic.net,代理组名称
  - DOMAIN-SUFFIX,nssurge.com,代理组名称
  - DOMAIN-SUFFIX,nyt.com,代理组名称
  - DOMAIN-SUFFIX,nytimes.com,代理组名称
  - DOMAIN-SUFFIX,omnigroup.com,代理组名称
  - DOMAIN-SUFFIX,onedrive.com,代理组名称
  - DOMAIN-SUFFIX,onenote.com,代理组名称
  - DOMAIN-SUFFIX,ooyala.com,代理组名称
  - DOMAIN-SUFFIX,openvpn.net,代理组名称
  - DOMAIN-SUFFIX,openwrt.org,代理组名称
  - DOMAIN-SUFFIX,orkut.com,代理组名称
  - DOMAIN-SUFFIX,osxdaily.com,代理组名称
  - DOMAIN-SUFFIX,outlook.com,代理组名称
  - DOMAIN-SUFFIX,ow.ly,代理组名称
  - DOMAIN-SUFFIX,paddleapi.com,代理组名称
  - DOMAIN-SUFFIX,parallels.com,代理组名称
  - DOMAIN-SUFFIX,parse.com,代理组名称
  - DOMAIN-SUFFIX,pdfexpert.com,代理组名称
  - DOMAIN-SUFFIX,periscope.tv,代理组名称
  - DOMAIN-SUFFIX,pinboard.in,代理组名称
  - DOMAIN-SUFFIX,pinterest.com,代理组名称
  - DOMAIN-SUFFIX,pixelmator.com,代理组名称
  - DOMAIN-SUFFIX,pixiv.net,代理组名称
  - DOMAIN-SUFFIX,playpcesor.com,代理组名称
  - DOMAIN-SUFFIX,playstation.com,代理组名称
  - DOMAIN-SUFFIX,playstation.com.hk,代理组名称
  - DOMAIN-SUFFIX,playstation.net,代理组名称
  - DOMAIN-SUFFIX,playstationnetwork.com,代理组名称
  - DOMAIN-SUFFIX,pushwoosh.com,代理组名称
  - DOMAIN-SUFFIX,rime.im,代理组名称
  - DOMAIN-SUFFIX,servebom.com,代理组名称
  - DOMAIN-SUFFIX,sfx.ms,代理组名称
  - DOMAIN-SUFFIX,shadowsocks.org,代理组名称
  - DOMAIN-SUFFIX,sharethis.com,代理组名称
  - DOMAIN-SUFFIX,shazam.com,代理组名称
  - DOMAIN-SUFFIX,skype.com,代理组名称
  - DOMAIN-SUFFIX,smartdnsProxy.com,代理组名称
  - DOMAIN-SUFFIX,smartmailcloud.com,代理组名称
  - DOMAIN-SUFFIX,sndcdn.com,代理组名称
  - DOMAIN-SUFFIX,sony.com,代理组名称
  - DOMAIN-SUFFIX,soundcloud.com,代理组名称
  - DOMAIN-SUFFIX,sourceforge.net,代理组名称
  - DOMAIN-SUFFIX,spotify.com,代理组名称
  - DOMAIN-SUFFIX,squarespace.com,代理组名称
  - DOMAIN-SUFFIX,sstatic.net,代理组名称
  - DOMAIN-SUFFIX,st.luluku.pw,代理组名称
  - DOMAIN-SUFFIX,stackoverflow.com,代理组名称
  - DOMAIN-SUFFIX,startpage.com,代理组名称
  - DOMAIN-SUFFIX,staticflickr.com,代理组名称
  - DOMAIN-SUFFIX,steamcommunity.com,代理组名称
  - DOMAIN-SUFFIX,symauth.com,代理组名称
  - DOMAIN-SUFFIX,symcb.com,代理组名称
  - DOMAIN-SUFFIX,symcd.com,代理组名称
  - DOMAIN-SUFFIX,tapbots.com,代理组名称
  - DOMAIN-SUFFIX,tapbots.net,代理组名称
  - DOMAIN-SUFFIX,tdesktop.com,代理组名称
  - DOMAIN-SUFFIX,techcrunch.com,代理组名称
  - DOMAIN-SUFFIX,techsmith.com,代理组名称
  - DOMAIN-SUFFIX,thepiratebay.org,代理组名称
  - DOMAIN-SUFFIX,theverge.com,代理组名称
  - DOMAIN-SUFFIX,time.com,代理组名称
  - DOMAIN-SUFFIX,timeinc.net,代理组名称
  - DOMAIN-SUFFIX,tiny.cc,代理组名称
  - DOMAIN-SUFFIX,tinypic.com,代理组名称
  - DOMAIN-SUFFIX,tmblr.co,代理组名称
  - DOMAIN-SUFFIX,todoist.com,代理组名称
  - DOMAIN-SUFFIX,trello.com,代理组名称
  - DOMAIN-SUFFIX,trustasiassl.com,代理组名称
  - DOMAIN-SUFFIX,tumblr.co,代理组名称
  - DOMAIN-SUFFIX,tumblr.com,代理组名称
  - DOMAIN-SUFFIX,tweetdeck.com,代理组名称
  - DOMAIN-SUFFIX,tweetmarker.net,代理组名称
  - DOMAIN-SUFFIX,twitch.tv,代理组名称
  - DOMAIN-SUFFIX,txmblr.com,代理组名称
  - DOMAIN-SUFFIX,typekit.net,代理组名称
  - DOMAIN-SUFFIX,ubertags.com,代理组名称
  - DOMAIN-SUFFIX,ublock.org,代理组名称
  - DOMAIN-SUFFIX,ubnt.com,代理组名称
  - DOMAIN-SUFFIX,ulyssesapp.com,代理组名称
  - DOMAIN-SUFFIX,urchin.com,代理组名称
  - DOMAIN-SUFFIX,usertrust.com,代理组名称
  - DOMAIN-SUFFIX,v.gd,代理组名称
  - DOMAIN-SUFFIX,vimeo.com,代理组名称
  - DOMAIN-SUFFIX,vimeocdn.com,代理组名称
  - DOMAIN-SUFFIX,vine.co,代理组名称
  - DOMAIN-SUFFIX,vivaldi.com,代理组名称
  - DOMAIN-SUFFIX,vox-cdn.com,代理组名称
  - DOMAIN-SUFFIX,vsco.co,代理组名称
  - DOMAIN-SUFFIX,vultr.com,代理组名称
  - DOMAIN-SUFFIX,w.org,代理组名称
  - DOMAIN-SUFFIX,w3schools.com,代理组名称
  - DOMAIN-SUFFIX,webtype.com,代理组名称
  - DOMAIN-SUFFIX,wikiwand.com,代理组名称
  - DOMAIN-SUFFIX,wikileaks.org,代理组名称
  - DOMAIN-SUFFIX,wikimedia.org,代理组名称
  - DOMAIN-SUFFIX,wikipedia.com,代理组名称
  - DOMAIN-SUFFIX,wikipedia.org,代理组名称
  - DOMAIN-SUFFIX,windows.com,代理组名称
  - DOMAIN-SUFFIX,windows.net,代理组名称
  - DOMAIN-SUFFIX,wire.com,代理组名称
  - DOMAIN-SUFFIX,wordpress.com,代理组名称
  - DOMAIN-SUFFIX,workflowy.com,代理组名称
  - DOMAIN-SUFFIX,wp.com,代理组名称
  - DOMAIN-SUFFIX,wsj.com,代理组名称
  - DOMAIN-SUFFIX,wsj.net,代理组名称
  - DOMAIN-SUFFIX,xda-developers.com,代理组名称
  - DOMAIN-SUFFIX,xeeno.com,代理组名称
  - DOMAIN-SUFFIX,xiti.com,代理组名称
  - DOMAIN-SUFFIX,yahoo.com,代理组名称
  - DOMAIN-SUFFIX,yimg.com,代理组名称
  - DOMAIN-SUFFIX,ying.com,代理组名称
  - DOMAIN-SUFFIX,yoyo.org,代理组名称
  - DOMAIN-SUFFIX,ytimg.com,代理组名称
  - DOMAIN-SUFFIX,telegram.me,代理组名称
  - DOMAIN-SUFFIX,v2ex.com,代理组名称
  - DOMAIN-SUFFIX,poe.com,代理组名称
  - DOMAIN-SUFFIX,poecdn.net,代理组名称
  - DOMAIN-SUFFIX,quoracdn.net,代理组名称
  - IP-CIDR,91.108.4.0/22,代理组名称
  - IP-CIDR,91.108.8.0/22,代理组名称
  - IP-CIDR,91.108.56.0/22,代理组名称
  - IP-CIDR,109.239.140.0/24,代理组名称
  - IP-CIDR,149.154.160.0/20,代理组名称
  - IP-CIDR,127.0.0.0/8,DIRECT
  - IP-CIDR,172.16.0.0/12,DIRECT
  - IP-CIDR,192.168.0.0/16,DIRECT
  - IP-CIDR,10.0.0.0/8,DIRECT
  - IP-CIDR,17.0.0.0/8,DIRECT
  - IP-CIDR,100.64.0.0/10,DIRECT
  - GEOIP,CN,DIRECT
  - MATCH,代理组名称
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;实际使用的时候只需要把服务器信息填进去即可。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;一点总结&lt;/h2&gt;
&lt;p&gt;这次折腾其实也算是一个小小的教训：很多时候我们用一套方案久了，就很少再去关注新的技术变化。&lt;/p&gt;
&lt;p&gt;从 Shadowsocks 到 Xray 的 Reality，其实整个代理技术这几年变化还挺大的。&lt;/p&gt;
&lt;p&gt;Reality 的思路本质上是让流量看起来更像正常的 TLS 连接，从而降低被识别的概率。配置虽然比早期的 Shadowsocks 稍微复杂一点，但整体来说稳定性确实更好。&lt;/p&gt;
&lt;p&gt;当然，对大多数人来说，可能直接使用现成服务会更省事。但如果像我一样喜欢折腾服务器，这种自己一步步搭起来的过程，其实也挺有意思的。&lt;/p&gt;
&lt;p&gt;至少下次再遇到问题的时候，大概也知道该从哪里开始排查了。&lt;/p&gt;
&lt;p&gt;‍&lt;/p&gt;</content:encoded><h:img src="/_astro/web.n3Pk-HlC.jpg"/><enclosure url="/_astro/web.n3Pk-HlC.jpg"/></item><item><title>从番剧墙到一个 Go CLI 工具</title><link>https://hejunjie.life/blog/k9dut5nf</link><guid isPermaLink="true">https://hejunjie.life/blog/k9dut5nf</guid><description>本文记录了基于个人番剧墙的需求，用 Go 制作的命令行工具，用于将 TMDB 片单数据导出为本地 JSON 文件</description><pubDate>Thu, 26 Feb 2026 04:58:27 GMT</pubDate><content:encoded>&lt;p&gt;前段时间我把自己的网站整理了一下，做了一个“番剧墙 / 影视墙”的页面。&lt;/p&gt;
&lt;p&gt;每个作品有封面、标题、评分、简介，看起来很清爽，也算是给自己这些年的观看记录做一个归档。&lt;/p&gt;
&lt;p&gt;一开始我也想过，这些信息要不要手动整理。&lt;/p&gt;
&lt;p&gt;理论上当然可以，但当数量慢慢多起来之后，问题就变得很现实：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;封面图从哪里来？&lt;/li&gt;
&lt;li&gt;简介要不要自己维护？&lt;/li&gt;
&lt;li&gt;某个平台没有这部作品怎么办？&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;后来我了解到了 TMDB。&lt;/p&gt;
&lt;p&gt;它是一个由全球社区共同维护的影视数据库，电影和剧集信息都很完整，也有免费的开放 API。如果是动态网站，直接在前端或服务端请求接口就能拿到数据，非常方便。&lt;/p&gt;
&lt;p&gt;但我的博客是静态博客。&lt;/p&gt;
&lt;p&gt;这意味着每一次构建都不太适合去实时请求外部 API，也不太希望页面强依赖外部接口的可用性。&lt;/p&gt;
&lt;p&gt;另外还有网络、图片外链、访问速度等问题。&lt;/p&gt;
&lt;p&gt;并且这些数据并非是变动频繁的数据，更多情况下数据只要产生了就完全不会变更了。&lt;/p&gt;
&lt;p&gt;所以我换了个思路。&lt;/p&gt;
&lt;p&gt;既然 TMDB 本身支持创建片单，那我是不是只需要维护一个“片单”，然后把片单数据一次性抓下来，转成我自己的静态数据？&lt;/p&gt;
&lt;p&gt;于是就有了这个项目：&lt;/p&gt;
&lt;p&gt;GitHub：&lt;a href=&quot;https://github.com/zxc7563598/tmdb-list-exporter&quot;&gt;tmdb-list-exporter&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;这个工具做了什么&lt;/h2&gt;
&lt;p&gt;它是一个用 Go 写的命令行工具。&lt;/p&gt;
&lt;p&gt;输入一个 TMDB 片单名称，它会自动抓取这个片单中的全部影视信息，包括：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;标题&lt;/li&gt;
&lt;li&gt;评分&lt;/li&gt;
&lt;li&gt;简介&lt;/li&gt;
&lt;li&gt;封面路径&lt;/li&gt;
&lt;li&gt;背景图路径&lt;/li&gt;
&lt;li&gt;类型&lt;/li&gt;
&lt;li&gt;上映时间等&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;然后导出为本地 JSON 文件。&lt;/p&gt;
&lt;p&gt;图片可以选择直接使用 TMDB 原图链接，也可以下载到本地，或者上传到自己的 OSS。&lt;/p&gt;
&lt;p&gt;对我来说，它的作用其实很简单：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;我只需要维护 TMDB 上的片单，其余的数据处理交给工具完成。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;这样我的网站构建阶段只读取本地 JSON 文件，不再依赖外部 API。&lt;/p&gt;
&lt;p&gt;如果以后需要迁移主题或者改展示方式，也只需要重新生成一次数据。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;为什么会想自己写&lt;/h2&gt;
&lt;p&gt;这其实不是一个复杂项目。&lt;/p&gt;
&lt;p&gt;但它刚好卡在一个比较微妙的位置：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;手动整理太麻烦&lt;/li&gt;
&lt;li&gt;直接在线请求又不适合静态博客&lt;/li&gt;
&lt;li&gt;已有工具要么偏重服务端，要么不太符合我的使用习惯&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;于是就干脆自己写一个。&lt;/p&gt;
&lt;p&gt;这个项目的结构也比较简单，大概拆成几个模块：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;tmdb：负责接口请求&lt;/li&gt;
&lt;li&gt;image：处理图片下载&lt;/li&gt;
&lt;li&gt;storage：本地或 OSS 存储&lt;/li&gt;
&lt;li&gt;exporter：生成 JSON 文件&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;本质上它只是做了一次“数据搬运和整理”。&lt;/p&gt;
&lt;p&gt;不过 CLI 项目本身对结构设计要求其实挺高的。&lt;/p&gt;
&lt;p&gt;比如参数解析、错误处理、批量请求、不同平台打包，这些东西虽然不复杂，但做完整还是需要一点耐心。&lt;/p&gt;
&lt;p&gt;我也顺便把它做成了多平台可执行文件，方便在不同环境下直接使用。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;它解决的其实不是技术问题&lt;/h2&gt;
&lt;p&gt;做完之后再回头看，我觉得它真正带来的变化不是“我可以抓取 TMDB 数据”——&lt;/p&gt;
&lt;p&gt;而是我不需要再手动维护一堆展示数据。&lt;/p&gt;
&lt;p&gt;我只维护一个片单。&lt;/p&gt;
&lt;p&gt;内容管理变成了“增删作品”这件事情本身，而不是围绕展示结构去改 JSON。&lt;/p&gt;
&lt;p&gt;对于一个静态博客来说，这种拆分其实挺舒服的。&lt;/p&gt;
&lt;p&gt;网站只是负责展示，数据生成是另外一个步骤。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;关于我的番剧墙&lt;/h2&gt;
&lt;p&gt;这个工具目前主要服务于我自己的网站：&lt;a href=&quot;https://hejunjie.life/anime&quot;&gt;查看我的番剧墙&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;我一直挺喜欢那种把观看记录长期整理下来的感觉。&lt;/p&gt;
&lt;p&gt;像是一条时间轴，在不经意的时间打开一看就会感觉到一种类似于「自己居然已经经历过这么多了」的时间的流逝感。&lt;/p&gt;
&lt;p&gt;对我来说，那更像是一份个人记录，而不是推荐列表。&lt;/p&gt;
&lt;p&gt;这个小工具只是让这件事变得轻松了一点。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;写在最后&lt;/h2&gt;
&lt;p&gt;项目本身不大，但它刚好是从一个真实的小需求出发，慢慢成型的。&lt;/p&gt;
&lt;p&gt;如果你也在做类似的影视墙、番剧墙，或者希望把 TMDB 的片单数据转成自己的静态资产，也许可以用得上。&lt;/p&gt;
&lt;p&gt;对我来说，它更多是一次对自己网站体系的补充，也是一次挺完整的 Go CLI 实践。&lt;/p&gt;
&lt;p&gt;以后可能还会继续围绕自己的内容系统做一些小工具。&lt;/p&gt;
&lt;p&gt;慢慢来。&lt;/p&gt;</content:encoded><h:img src="/_astro/go.aBeHx0xJ.jpg"/><enclosure url="/_astro/go.aBeHx0xJ.jpg"/></item><item><title>用 Go 实现一个可长期运行的 GitHub Webhook 服务实践</title><link>https://hejunjie.life/blog/id84hty4</link><guid isPermaLink="true">https://hejunjie.life/blog/id84hty4</guid><description>结合分层结构、依赖注入与双队列 Worker Pool 调度模型，实现一个可长期运行的 GitHub Webhook 服务，并通过 embed 打包前端为单一二进制，完整复盘工程实践细节。</description><pubDate>Thu, 12 Feb 2026 07:07:34 GMT</pubDate><content:encoded>&lt;p&gt;前段时间我写过一篇文章，&lt;a href=&quot;https://hejunjie.life/blog/aodj2421&quot;&gt;记录自己作为一名 PHP 开发者自学 Go 的过程&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;那篇更多是学习阶段的整理。这次则是一次完整实践的复盘。&lt;/p&gt;
&lt;p&gt;单点知识和系统能力之间始终存在差距。&lt;/p&gt;
&lt;p&gt;理解一个概念并不难，但要把多个能力组合起来，形成一个可以长期运行的系统，往往需要真实项目去反复打磨。很多看似基础的东西，只有亲手做过，理解才会真正扎实。&lt;/p&gt;
&lt;p&gt;最近我完成了一个小工具：&lt;strong&gt;github-webhook-listener&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;一个用 Go 实现的 GitHub Webhook 接收服务，可以根据规则执行 Shell 命令，并内置一个简单的 Vue 面板，用于查看运行状态和执行记录。&lt;/p&gt;
&lt;p&gt;功能本身并不复杂，AI 也完全可以在较短时间内生成类似的实现。但在实际开发过程中，我更在意的并不是功能本身，而是一些基础层面的设计问题：项目结构如何划分，依赖如何组织，边界如何定义，以及构建与部署如何简化。&lt;/p&gt;
&lt;p&gt;这些内容未必新鲜，但当它们被组合到一个完整系统中时，体会是不同的。&lt;/p&gt;
&lt;p&gt;项目地址我放在文章末尾，感兴趣可以自行查看下载使用。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;article/id84hty4/00001.png&quot; alt=&quot;0001&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;article/id84hty4/00002.png&quot; alt=&quot;0002&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;article/id84hty4/00003.png&quot; alt=&quot;0003&quot;&gt;&lt;/p&gt;
&lt;p&gt;下面我会从结构设计、并发模型以及构建方式三个方面，做一次相对完整的技术复盘。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;项目结构与职责划分&lt;/h2&gt;
&lt;p&gt;项目核心代码放在 &lt;code&gt;internal&lt;/code&gt; 目录：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;internal/
├── bootstrap
├── handler
├── service
├── repository
├── model
├── dto
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这种结构并不追求“标准答案”，重点在于依赖方向清晰。&lt;/p&gt;
&lt;h3&gt;repository&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;只负责数据库操作&lt;/li&gt;
&lt;li&gt;不包含业务判断&lt;/li&gt;
&lt;li&gt;不依赖 HTTP&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;service&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;负责业务逻辑&lt;/li&gt;
&lt;li&gt;调用 repository&lt;/li&gt;
&lt;li&gt;不处理 HTTP 细节&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;handler&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;只做参数解析与响应封装&lt;/li&gt;
&lt;li&gt;调用 service&lt;/li&gt;
&lt;li&gt;不包含核心逻辑&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;在功能简单时，这种分层似乎有些“多余”。&lt;/p&gt;
&lt;p&gt;但当涉及到任务调度、执行记录、重试机制时，结构边界开始体现价值。&lt;/p&gt;
&lt;p&gt;边界明确之后，功能扩展基本是“局部修改”，而不是结构性调整。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;在 bootstrap 中组织依赖关系&lt;/h2&gt;
&lt;p&gt;所有初始化逻辑集中在 &lt;code&gt;bootstrap&lt;/code&gt; 包中完成：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;初始化数据库&lt;/li&gt;
&lt;li&gt;创建 repository&lt;/li&gt;
&lt;li&gt;注入到 service&lt;/li&gt;
&lt;li&gt;注入到 handler&lt;/li&gt;
&lt;li&gt;注册路由&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;依赖关系在入口处完全展开，而不是在各个文件中隐式创建。&lt;/p&gt;
&lt;p&gt;这种方式带来的最大好处是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;对象生命周期清晰&lt;/li&gt;
&lt;li&gt;依赖方向可控&lt;/li&gt;
&lt;li&gt;替换实现时改动集中&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;在没有使用任何 DI 框架的情况下，通过显式构造函数完成依赖注入，本身就是对依赖关系的一种约束。&lt;/p&gt;
&lt;p&gt;当项目规模不大时，这种方式反而比自动注入更透明。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;双队列 Worker Pool 的并发调度模型&lt;/h2&gt;
&lt;p&gt;这个项目的核心之一，是执行 Shell 命令并控制并发数量。&lt;/p&gt;
&lt;p&gt;我实现的是一个“双队列 Worker Pool”结构，主要包含三个核心组件：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;任务生产者（Producer）&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;集中式调度器 + Worker Goroutine&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;结果处理器（Result Processor）&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;第一层：任务生产者&lt;/h3&gt;
&lt;p&gt;当 Webhook 触发或 Web 面板手动触发任务时，任务被封装为一个结构体，发送到调度队列。&lt;/p&gt;
&lt;p&gt;这一层只负责“生成任务”，不关心执行细节。&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;第二层：集中式调度器 + Worker Pool&lt;/h3&gt;
&lt;p&gt;调度器内部维护：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;一个任务输入队列&lt;/li&gt;
&lt;li&gt;一个固定数量的 worker goroutine&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;调度流程：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;调度器从任务队列中取出任务&lt;/li&gt;
&lt;li&gt;分发给空闲 worker&lt;/li&gt;
&lt;li&gt;worker 执行 Shell 命令&lt;/li&gt;
&lt;li&gt;将执行结果发送到结果队列&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;worker 数量可控，因此系统并发是有上限的。&lt;/p&gt;
&lt;p&gt;这种结构的优点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;并发可控&lt;/li&gt;
&lt;li&gt;不会因为 Webhook 高频触发而无限创建 goroutine&lt;/li&gt;
&lt;li&gt;任务调度逻辑集中管理&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;相比“每来一个请求直接开 goroutine 执行”的写法，这种结构在可控性和可扩展性上更好。&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;第三层：结果处理器&lt;/h3&gt;
&lt;p&gt;worker 不直接写数据库，而是把结果推送到结果队列。&lt;/p&gt;
&lt;p&gt;结果处理器负责：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;更新执行记录&lt;/li&gt;
&lt;li&gt;写入数据库&lt;/li&gt;
&lt;li&gt;处理重试逻辑（如果有）&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这样做的目的，是进一步解耦：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;执行逻辑专注执行&lt;/li&gt;
&lt;li&gt;持久化逻辑专注记录&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这就是“双队列”的意义：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;队列一：任务调度&lt;/li&gt;
&lt;li&gt;队列二：结果处理&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这种分离在系统规模变大时尤为重要，因为执行耗时和持久化耗时是两个不同维度的问题。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Makefile 作为构建入口&lt;/h2&gt;
&lt;p&gt;项目使用 Makefile 统一管理：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;后端构建&lt;/li&gt;
&lt;li&gt;前端构建&lt;/li&gt;
&lt;li&gt;交叉编译&lt;/li&gt;
&lt;li&gt;发布打包&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Makefile 在这里的意义并不是“少打几行命令”，而是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;所有构建流程被显式记录&lt;/li&gt;
&lt;li&gt;新环境下可直接复现&lt;/li&gt;
&lt;li&gt;发布步骤标准化&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;当一个项目开始涉及前后端协作、交叉编译和发布时，构建流程本身就成为项目的一部分。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;使用 embed 将前端资源打包进二进制&lt;/h2&gt;
&lt;p&gt;这是我在这个项目中感受最明显的“Go 工程优势”。&lt;/p&gt;
&lt;p&gt;前端使用 Vue 构建完成后，静态资源通过 &lt;code&gt;embed&lt;/code&gt; 打包进 Go 二进制中。&lt;/p&gt;
&lt;p&gt;然后通过：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;http.FileServer(http.FS(...))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;直接提供访问。&lt;/p&gt;
&lt;p&gt;最终效果是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;只有一个可执行文件&lt;/li&gt;
&lt;li&gt;不需要 Node 环境&lt;/li&gt;
&lt;li&gt;不需要单独部署前端&lt;/li&gt;
&lt;li&gt;不依赖外部静态文件目录&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;从架构上看，它仍然是前后端分离：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;前端独立开发&lt;/li&gt;
&lt;li&gt;后端提供 API&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;但从交付形态看，它又像是传统单体应用：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;单文件分发&lt;/li&gt;
&lt;li&gt;直接运行&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这种组合非常适合工具型项目和内部服务。&lt;/p&gt;
&lt;p&gt;Go 在这一点上确实有明显优势：编译后就是完整产物，不需要运行时环境，不依赖包管理器，不依赖额外解释器。&lt;/p&gt;
&lt;p&gt;分发成本几乎为零。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;写在最后&lt;/h2&gt;
&lt;p&gt;这个项目没有刻意追求复杂设计，也没有引入额外框架。&lt;/p&gt;
&lt;p&gt;它更像是一次完整的工程实践：把分层、依赖组织、并发控制、构建管理这些已经学过的能力组合在一起，形成一个可长期运行的系统。&lt;/p&gt;
&lt;p&gt;我自己已经在实际环境中持续使用它，用来自动化部署和执行脚本，稳定性和可维护性都符合预期。对我来说，它已经从“练手项目”变成了日常工具。&lt;/p&gt;
&lt;p&gt;如果你刚好也需要一个简单的 GitHub Webhook 执行工具，可以直接拿去用；&lt;/p&gt;
&lt;p&gt;如果你正在学习 Go，想找一个结构完整、但复杂度可控的小项目作为参考，也可以看看实现细节。&lt;/p&gt;
&lt;p&gt;GitHub 仓库地址：&lt;a href=&quot;https://github.com/zxc7563598/github-webhook-listener&quot;&gt;点击查看&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;有问题或者想法，也欢迎直接在 GitHub 上交流。&lt;/p&gt;</content:encoded><h:img src="/_astro/go.aBeHx0xJ.jpg"/><enclosure url="/_astro/go.aBeHx0xJ.jpg"/></item><item><title>从“能写 Go”到“写得对 Go”：一名 PHP 开发者的补课与重构</title><link>https://hejunjie.life/blog/aodj2421</link><guid isPermaLink="true">https://hejunjie.life/blog/aodj2421</guid><description>站在已经能用 Go 干活的前提下，系统补齐 PHP 开发者在 slice、map、指针、并发等方面最容易“靠感觉”的认知空缺，持续更新的学习与实践记录</description><pubDate>Thu, 08 Jan 2026 11:09:37 GMT</pubDate><content:encoded>&lt;h2&gt;写在前面&lt;/h2&gt;
&lt;p&gt;我是一名从事了几年的 PHP 开发者，平时以独立开发为主。主流的 PHP 框架基本都接触过，也做过不少实际跑在线上的项目。&lt;/p&gt;
&lt;p&gt;后来因为工作和个人兴趣的原因，开始逐渐接触 Go，也用 Go 做过一些真实的东西。&lt;/p&gt;
&lt;p&gt;比如，用 Gin + Vue + Wails 做过 PC 应用，在 Ubuntu 服务器上跑过 Go 服务，也写过一些用于接收 GitHub Webhook 执行 shell 脚本的小工具。&lt;/p&gt;
&lt;p&gt;从结果上看，这些项目都能正常运行，功能也正常。&lt;/p&gt;
&lt;p&gt;但我自己很清楚，这并不等于我“真正掌握了 Go”。&lt;/p&gt;
&lt;p&gt;很多时候，我其实是在用多年写 PHP 积累下来的直觉去写 Go。&lt;/p&gt;
&lt;p&gt;代码能跑，但对一些关键细节并没有完全的确定感：struct、slice、map 到底是值还是引用，指针什么时候该用、什么时候不该用，并发写法会不会在某些场景下出问题，这些问题经常是靠经验和感觉在兜底。&lt;/p&gt;
&lt;p&gt;说得直白一点就是：&lt;strong&gt;我能用 Go 干活，但并不总是确定自己写的是不是“对的 Go”。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;市面上的 Go 教程大多从 0 开始，这对我来说反而有点不太合适，从头跟着学，会花大量时间在已经了解的内容上；跳着看，又很容易因为缺失上下文而看不明白真正重要的部分。&lt;/p&gt;
&lt;p&gt;而我真正想补的，也并不是 Web 框架的用法，而是那些在 PHP 中不存在、却在 Go 中非常关键的基础差异。&lt;/p&gt;
&lt;p&gt;为了逼自己真正学明白，也为了以后可以随时回看，我选择把整个过程整理成一篇持续更新的文章。&lt;/p&gt;
&lt;p&gt;这不是一篇从 0 开始的 Go 入门教程，也不追求覆盖所有语言特性。&lt;/p&gt;
&lt;p&gt;它更像是一名 PHP 开发者，在已经“能写 Go”的前提下，回头把那些一直靠感觉的地方重新补扎实的过程记录。&lt;/p&gt;
&lt;h2&gt;一、重新认识 Go&lt;/h2&gt;
&lt;h3&gt;1. 为什么 Go 不适合用「脚本语言思维」去理解&lt;/h3&gt;
&lt;p&gt;如果之前没有接触过常驻内存框架，刚开始接触 Go 时，很容易下意识地用写 PHP、Python 这类脚本语言的方式去理解它：&lt;/p&gt;
&lt;p&gt;无非是语法更严格一点、类型更强一点、性能更好一点。&lt;/p&gt;
&lt;p&gt;在早期 PHP 那种“请求即生命周期”的模型下，这种理解其实是成立的。&lt;/p&gt;
&lt;p&gt;一次请求执行完，进程结束，内存和状态被整体回收，很多问题都会被运行环境自然兜底，开发者也不需要太关心它们。&lt;/p&gt;
&lt;p&gt;这几年，PHP 也出现了 webman、Swoole 这类 ​&lt;strong&gt;常驻内存框架&lt;/strong&gt;，把 PHP 拉进了“长期运行服务”的世界。它们确实在使用体验上缩小了 PHP 和 Go 之间的距离，也让不少原本被隐藏的问题逐渐浮现出来。&lt;/p&gt;
&lt;p&gt;但关键的区别在于：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Go 是从语言和运行时层面，就假设程序会长期运行；而 PHP 是在原有的脚本模型之上，通过框架去“补”这一点。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;在 Go 里，长期运行不是一种特殊用法，而是一种 ​&lt;strong&gt;默认前提&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;全局状态如何管理、资源如何释放、并发如何调度，这些问题不是“需不需要考虑”，而是语言和运行时必须正面解决的核心设计。&lt;/p&gt;
&lt;p&gt;如果仍然用“写完就结束”的脚本思维去理解 Go，很容易忽略这些前提，写出那种 &lt;strong&gt;短期跑得通、长期一定会出问题&lt;/strong&gt; 的代码。&lt;/p&gt;
&lt;p&gt;所以，理解 Go 的关键，并不是把它当成“支持常驻内存的脚本语言”&lt;/p&gt;
&lt;p&gt;而是意识到：&lt;strong&gt;它从一开始，就是为长期运行的服务而设计的语言。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;2. Go 的编译模型、运行时与 PHP 的本质差异&lt;/h3&gt;
&lt;p&gt;Go 和 PHP 在使用体验上的差异，根源并不在语法层面，而在于它们背后的 ​&lt;strong&gt;编译模型和运行时设计&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;PHP 本质上是一门 ​&lt;strong&gt;解释型脚本语言&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;即便开启了 OPcache，代码依然是在运行时由 Zend VM 解析并执行的。&lt;/p&gt;
&lt;p&gt;多年来，PHP 的语言和运行时设计始终围绕着“快速启动、快速执行、快速回收”展开，这也让它天然适配以请求为单位的执行模型。&lt;/p&gt;
&lt;p&gt;Go 则是一门 ​&lt;strong&gt;编译型语言&lt;/strong&gt;​。&lt;/p&gt;
&lt;p&gt;在 &lt;code&gt;go build&lt;/code&gt;​ 阶段，源码会被编译成一个完整的可执行文件，语言本身、标准库以及运行时都会被整体打包进去。&lt;/p&gt;
&lt;p&gt;程序启动时，Go 的 runtime 会先完成调度器、内存管理和 GC 的初始化，然后才进入 &lt;code&gt;main&lt;/code&gt; 函数开始执行用户代码。&lt;/p&gt;
&lt;p&gt;这带来的一个核心差异是：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;PHP 更像是在“执行一段代码”，而 Go 更像是在“启动一个程序”。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;在 Go 中，运行时并不是隐藏在背后的执行引擎，而是语言设计的一部分。&lt;/p&gt;
&lt;p&gt;goroutine、调度模型、垃圾回收、并发语义，都是在语言层面就被明确建模的能力，而不是依赖框架或扩展后置补齐的功能。&lt;/p&gt;
&lt;p&gt;因此，Go 的代码天然假设程序会长期存在，状态会被复用，资源需要被明确管理；&lt;/p&gt;
&lt;p&gt;而 PHP 即便在常驻内存或长生命周期的框架下，语言本身依然保留着强烈的脚本时代特征：生命周期并不显式，状态容易通过全局或上下文隐式扩散，也缺乏语言级的并发原语。&lt;/p&gt;
&lt;p&gt;并发能力更多是由框架或扩展提供，但开发者在写代码时，很容易默认“这是顺序执行的”。&lt;/p&gt;
&lt;p&gt;这也解释了为什么，同样是在“做服务”，Go 更强调启动流程、生命周期和运行时行为，而 PHP 更关注单次请求的处理过程。&lt;/p&gt;
&lt;p&gt;两者关注的重点，从一开始就不在同一个位置。&lt;/p&gt;
&lt;h3&gt;3. Go 项目结构与 &lt;code&gt;go mod&lt;/code&gt; 的真实作用&lt;/h3&gt;
&lt;p&gt;如果你有 PHP 或前端背景，可以先把 &lt;code&gt;go.mod&lt;/code&gt;​ 简单理解成 &lt;code&gt;composer.json&lt;/code&gt;​ / &lt;code&gt;package.json&lt;/code&gt;​。&lt;/p&gt;
&lt;p&gt;​&lt;code&gt;go mod tidy&lt;/code&gt;​、&lt;code&gt;go mod download&lt;/code&gt;​ 的使用体验，也确实很像 &lt;code&gt;composer install&lt;/code&gt;​。&lt;/p&gt;
&lt;p&gt;在入门阶段，把它们都当作​&lt;strong&gt;声明和管理项目依赖的工具&lt;/strong&gt;，这个理解是完全成立的。&lt;/p&gt;
&lt;p&gt;真正的差异不在“怎么写”，而在于：&lt;/p&gt;
&lt;p&gt;​&lt;strong&gt;依赖是在什么时候、以什么方式参与到程序里的&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;在 PHP 或前端项目中，依赖代码会被拉进项目目录（&lt;code&gt;vendor&lt;/code&gt;​ / &lt;code&gt;node_modules&lt;/code&gt;​），作为项目源码的一部分存在，并在&lt;strong&gt;运行时&lt;/strong&gt;由解释器或运行环境加载；&lt;/p&gt;
&lt;p&gt;而在 Go 中，依赖同样会被下载，但它们存在于 Go 的&lt;strong&gt;模块缓存&lt;/strong&gt;中，不属于项目源码，只在&lt;strong&gt;编译阶段&lt;/strong&gt;被解析、编译，并最终被打包进可执行文件。&lt;/p&gt;
&lt;p&gt;这也决定了 &lt;code&gt;go mod&lt;/code&gt;​ 的真实关注点：&lt;/p&gt;
&lt;p&gt;它关心的并不是“运行时需要哪些库”，而是：&lt;/p&gt;
&lt;p&gt;​&lt;strong&gt;我要构建出一个什么样的程序&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;同一份源码、同一份 &lt;code&gt;go.mod&lt;/code&gt;​，目标是无论在哪台机器上构建，最终得到的都是​&lt;strong&gt;行为一致的二进制文件&lt;/strong&gt;。依赖不是项目的一部分，而是构建过程中的输入。&lt;/p&gt;
&lt;p&gt;基于这个前提，Go 的项目结构看起来就会非常克制。&lt;/p&gt;
&lt;p&gt;目录结构更多是在表达 ​&lt;strong&gt;package 之间的编译关系&lt;/strong&gt;，而不是应用层面的分层设计。&lt;/p&gt;
&lt;p&gt;一个目录就是一个 package，package 是最小的编译和依赖单位，代码如何组织，本质上是在服务于“如何被编译”和“如何被发布”。&lt;/p&gt;
&lt;p&gt;可以用下面这个对照，快速感受这种差异：&lt;/p&gt;
&lt;p&gt;| 对比点         | PHP / 前端                        | Go                     |
| -------------- | --------------------------------- | ---------------------- |
| 依赖声明文件   | composer.json / package.json      | go.mod                 |
| 安装依赖       | composer install / npm install    | go mod tidy / download |
| 依赖存放位置   | 项目目录（vendor / node_modules） | 模块缓存（不在项目中） |
| 依赖参与阶段   | 运行时加载                        | 编译期解析             |
| 项目结构关注点 | 应用分层                          | 包与编译边界           |
| 最终产物       | 源码 + 运行环境                   | 单一可执行文件         |&lt;/p&gt;
&lt;p&gt;整体来看，&lt;code&gt;go mod&lt;/code&gt;​ 表面上像是一个依赖管理工具，但它真正服务的是 Go 的​&lt;strong&gt;编译模型&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;这也是为什么 Go 项目往往结构简单、层级不多，却非常适合长期运行的工程型服务。&lt;/p&gt;
&lt;p&gt;它从一开始，就把“如何构建”和“如何交付”放在了设计的核心位置。&lt;/p&gt;
&lt;h3&gt;4. package、import 与依赖边界（为什么 Go 讨厌循环依赖）&lt;/h3&gt;
&lt;p&gt;在 Go 中，&lt;code&gt;package&lt;/code&gt;​ 是最核心的组织单位。&lt;/p&gt;
&lt;p&gt;一个目录就是一个 package，而 package 同时也承担着&lt;strong&gt;编译边界、依赖边界和可见性边界&lt;/strong&gt;这几件事情。&lt;/p&gt;
&lt;p&gt;代码在 Go 里的归属关系，其实是先属于某个 package，再通过 &lt;code&gt;import&lt;/code&gt; 被其他 package 使用，而不是一开始就挂在某个“项目”之下。&lt;/p&gt;
&lt;p&gt;从这个角度看，&lt;code&gt;import&lt;/code&gt;​ 在 Go 里也不是简单的“引用文件”，它更像是在明确声明一件事：&lt;/p&gt;
&lt;p&gt;​&lt;strong&gt;这个 package 在编译时依赖另一个 package 的产出结果&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;所以，Go 的依赖关系，本质上是一张​&lt;strong&gt;编译期的依赖图&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;也正因为依赖是在编译期被严格确定的，Go 对 package 之间的依赖边界非常敏感，并且明确禁止循环依赖。&lt;/p&gt;
&lt;p&gt;如果 A import B，B 又 import A，在不少脚本语言里，往往还能通过一些方式“绕过去”，比如延迟加载、运行时决定执行顺序等。&lt;/p&gt;
&lt;p&gt;但在 Go 的模型里，这种关系本身就无法成立：编译器既无法确定编译顺序，也无法保证依赖结果是稳定的。&lt;/p&gt;
&lt;p&gt;不过，禁止循环依赖的意义，并不只是“编译器做不到”。&lt;/p&gt;
&lt;p&gt;从 Go 的设计取向来看，​&lt;strong&gt;循环依赖本身就被视为一种值得警惕的结构信号&lt;/strong&gt;，它通常意味着：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;package 的职责边界不够清晰&lt;/li&gt;
&lt;li&gt;抽象层级开始变得混乱&lt;/li&gt;
&lt;li&gt;状态和逻辑在不同层之间相互牵扯&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;在这样的前提下，禁止循环依赖，实际上是在逼着你把依赖关系整理成​&lt;strong&gt;单向的、有层次的结构&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;这也是为什么在不少 Go 项目中，会逐渐形成一些比较一致的结构特征：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;偏底层的 package 不依赖上层逻辑&lt;/li&gt;
&lt;li&gt;通用能力被拆到更独立的 package 中&lt;/li&gt;
&lt;li&gt;通过接口来反转依赖方向，而不是让 package 之间互相 import&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;换句话说，Go 并不是单纯在“语法层面不允许循环依赖”，而是通过语言规则，把&lt;strong&gt;依赖边界&lt;/strong&gt;这件事提前暴露出来，让你在写代码的时候就必须面对它。&lt;/p&gt;
&lt;p&gt;如果简单点来概括这种思路，大概可以这样理解：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;在 Go 中，package 更像是在声明编译边界；​&lt;/li&gt;
&lt;li&gt;&lt;code&gt;import&lt;/code&gt; 是在说明依赖方向；&lt;/li&gt;
&lt;li&gt;禁止循环依赖，是为了让这些关系始终保持清晰和可推导。&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;二、值语义：PHP 开发者最容易踩的第一坑&lt;/h2&gt;
&lt;h3&gt;5. struct 是值，不是对象&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;当前问题存在示例代码，可以前往&lt;a href=&quot;https://github.com/zxc7563598/go-lab/commit/8b76765b4a15a2dd2f3eae304387058f4c301ff9&quot;&gt;GitHub查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;在 Go 里，有一个和 PHP 认知明显不同的地方：​&lt;strong&gt;值语义&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;在 PHP 中，我们通常把结构体或对象理解为有身份的实体：把它传给函数或者赋值给另一个变量，好像一直在操作同一份数据。&lt;/p&gt;
&lt;p&gt;而在 Go 里，&lt;code&gt;struct&lt;/code&gt;​ 的默认语义是 ​&lt;strong&gt;值&lt;/strong&gt;。这意味着一些看似自然的操作，其实背后发生的是复制：赋值会生成副本，函数传参会生成副本，方法调用在很多情况下也会生成副本。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;type Counter struct {
	n int
}

a := Counter{n: 10}
b := a
b.n++
fmt.Println(a.n, b.n) // 10, 11
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在这个例子里，虽然表面上只是一次赋值，但 &lt;code&gt;a&lt;/code&gt;​ 和 &lt;code&gt;b&lt;/code&gt;​ 已经是两份独立的数据。&lt;/p&gt;
&lt;p&gt;从这个角度看，Go 并没有把共享状态作为默认行为，而是把 &lt;strong&gt;数据的传递与复制&lt;/strong&gt; 放在了显式可见的位置。&lt;/p&gt;
&lt;h3&gt;6. 函数参数传递：值拷贝 vs 指针&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;当前问题存在示例代码，可以前往&lt;a href=&quot;https://github.com/zxc7563598/go-lab/commit/8b76765b4a15a2dd2f3eae304387058f4c301ff9&quot;&gt;GitHub查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;值语义在函数参数传递上也会体现出来。&lt;/p&gt;
&lt;p&gt;当 struct 作为函数参数传入时，Go 会生成一份副本：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func inc(c Counter) {
	c.n++
}

c := Counter{n: 10}
inc(c)
fmt.Println(c.n) // 10
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可以看到，函数内部对 &lt;code&gt;c&lt;/code&gt;​ 的修改没有影响外部的 &lt;code&gt;c&lt;/code&gt;。如果希望函数内部修改能够影响外部，就需要使用指针：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func incPtr(c *Counter) {
	c.n++
}

incPtr(&amp;#x26;c)
fmt.Println(c.n) // 11
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;方法调用遵循同样规则：接收者是值还是指针，决定了方法内部操作的是副本还是原始数据。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func (c Counter) incByValue()    { c.n++ }  // 操作副本
func (c *Counter) incByPointer() { c.n++ }  // 操作原值

c := Counter{n: 10}
c.incByValue()
fmt.Println(c.n) // 10

c.incByPointer()
fmt.Println(c.n) // 11
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;总结起来：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Go 的 struct 默认是值类型，赋值、函数传参、方法调用都会生成副本。&lt;/li&gt;
&lt;li&gt;想要在函数或方法里修改原数据，需要显式使用指针。&lt;/li&gt;
&lt;li&gt;slice、map、channel 等引用类型除外，它们内部数据的修改会反映到外部，但整体赋值仍然是复制副本。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;相比 PHP 的对象语义，这种行为更加明确：共享必须被显式表达，而不是隐式存在。&lt;/p&gt;
&lt;h3&gt;7. 返回 struct、返回指针、返回 interface 的区别&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;当前问题存在示例代码，可以前往&lt;a href=&quot;https://github.com/zxc7563598/go-lab/commit/07bcd339c407dd2cefb6fe2fb5c2b98777dd05b8&quot;&gt;GitHub查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;在 Go 里，函数返回值既可以是值类型，也可以是指针类型，还可以是接口类型。我在学习中对这三种返回方式的行为做了观察，总结如下：&lt;/p&gt;
&lt;p&gt;当函数返回一个 struct 时，返回的是一份​&lt;strong&gt;副本&lt;/strong&gt;。调用方拿到的是独立的数据，修改返回值不会影响函数内部或其他实例：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;type Counter struct { n int }

func NewCounterVal() Counter {
	return Counter{n: 1}
}

c := NewCounterVal()
c.n++
fmt.Println(c.n) // 2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;每次返回都是独立副本，适合小型 struct，不需要共享状态。内存上会复制整个 struct，如果 struct 较大，可能有开销。返回 struct 类似于函数传值，强调复制而非共享。&lt;/p&gt;
&lt;p&gt;返回 struct 的指针时，调用方拿到的是原始对象的引用，可以修改原始数据：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func NewCounterPtr() *Counter {
	return &amp;#x26;Counter{n: 1}
}

c1 := NewCounterPtr()
c2 := c1
c2.n++
fmt.Println(c1.n) // 2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;返回值是指针，操作共享同一份数据，避免复制大型 struct，提高性能。修改原始数据变得显式可见，和函数传指针参数语义一致。&lt;/p&gt;
&lt;p&gt;接口返回值稍微复杂一些。接口内部存储的是​&lt;strong&gt;类型信息 + 值&lt;/strong&gt;：如果返回值实现是 struct 值，接口内部存储的是副本；如果返回值实现是指针，接口内部存储的是指针，调用方法会修改原对象。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;type Counterer interface {
	Inc()
	Value() int
}

func NewCounterInterface() Counterer {
	return &amp;#x26;Counter{n: 1} // 返回指针实现
}

c := NewCounterInterface()
c.Inc()
fmt.Println(c.Value()) // 修改生效
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;接口返回行为取决于传入的是值类型实现还是指针类型实现。对 PHP 开发者来说，接口不像对象那样天然共享状态，需要理解内部存储机制。&lt;/p&gt;
&lt;p&gt;| 返回类型  | 内部行为        | 是否共享原数据 | 适用场景                 |
| --------- | --------------- | -------------- | ------------------------ |
| struct    | 复制整个 struct | 否             | 小型 struct，不修改状态  |
| *struct  | 复制指针        | 是             | 修改状态或大型 struct    |
| interface | 存储值或指针    | 取决于实现     | 抽象类型，可存放值或指针 |&lt;/p&gt;
&lt;p&gt;核心规律：Go 的函数返回值和参数传递类似，默认是值拷贝。想要共享原数据，需要显式使用指针。接口稍微复杂，需要理解内部存储机制。&lt;/p&gt;
&lt;h3&gt;8. 方法接收者：值接收者 vs 指针接收者&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;当前问题存在示例代码，可以前往&lt;a href=&quot;https://github.com/zxc7563598/go-lab/commit/07bcd339c407dd2cefb6fe2fb5c2b98777dd05b8&quot;&gt;GitHub查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;在 Go 中，方法本质上就是带接收者参数的函数：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func (c Counter) IncByValue()    { ... } // 值接收者
func (c *Counter) IncByPointer() { ... } // 指针接收者
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;接收者类型决定了方法内部修改是否会影响原对象。&lt;/p&gt;
&lt;p&gt;值接收者方法内部操作的是​&lt;strong&gt;副本&lt;/strong&gt;，修改不会影响原对象。适合小型 struct 或只读操作：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;c := Counter{n: 10}
c.IncByValue()
fmt.Println(c.n) // 10，原值不变
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;指针接收者方法内部操作的是​&lt;strong&gt;原始对象&lt;/strong&gt;，修改会反映到原对象。适合需要修改状态或大型 struct：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;c := &amp;#x26;Counter{n: 10}
c.IncByPointer()
fmt.Println(c.n) // 11，修改生效
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;方法接收者选择的原则大致如下：只读或小型 struct 可用值接收者；需要修改状态或大型 struct 通常用指针接收者；接口方法一般使用指针接收者，保证修改行为一致。&lt;/p&gt;
&lt;p&gt;| 接收者类型 | 操作数据 | 是否修改原对象 | 适用场景              |
| ---------- | -------- | -------------- | --------------------- |
| 值接收者   | 副本     | 否             | 小 struct，只读操作   |
| 指针接收者 | 原始对象 | 是             | 修改状态或大型 struct |&lt;/p&gt;
&lt;p&gt;核心规律：方法接收者语义和参数传递一致，默认值传递，显式使用指针才能共享数据。&lt;/p&gt;
&lt;h3&gt;9. 「什么时候必须用指针」的经验法则&lt;/h3&gt;
&lt;p&gt;观察下来，大概可以这样理解：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;修改原始数据的时候&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;方法或者函数内部如果希望改变外部的数据，必须用指针。&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func IncPtr(c *Counter) { c.n++ }       // 参数是指针
func (c *Counter) Inc() { c.n++ }       // 方法接收者是指针
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;struct 较大或者复制成本明显的时候&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;当 struct 字段比较多，或者占用内存不小，传值会复制整个结构体。&lt;/li&gt;
&lt;li&gt;指针传递可以避免这个复制开销，这时使用指针不是为了共享状态，而只是效率考虑。&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func ProcessLargeStruct(s *LargeStruct) { ... } // 避免复制大对象
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;接口方法涉及内部状态修改&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;如果一个 struct 实现接口，方法会修改内部状态，那么接收者通常要用指针。&lt;/li&gt;
&lt;li&gt;因为接口内部存的是类型 + 数据，如果传入的是值类型，实现方法会操作副本，修改不会反映到原对象。&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func NewCounterInterface() Counterer {
    return &amp;#x26;Counter{n:1} // 返回的是指针
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;希望行为统一或者更容易理解的时候&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;即便 struct 本身不大，如果想让所有方法行为一致，也可以统一用指针接收者。&lt;/li&gt;
&lt;li&gt;这样方法调用不会因为值还是指针而表现不同，接口实现也更直观。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;整理下来，我的理解可以这样总结：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;必须修改原对象 → 用指针&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;struct 较大或复制开销明显 → 用指针&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;接口方法需要修改内部状态 → 用指针&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;希望行为统一 → 可以统一使用指针接收者&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;总体感觉是：Go 的默认行为是值语义，复制是自然发生的，共享状态不会自动发生，需要用指针显式表达。值类型适合只读或者独立副本操作，指针类型则可以让修改和共享变得明确。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;三、指针：只学 Go 中真正需要的那一部分&lt;/h2&gt;
&lt;h3&gt;​10. &lt;code&gt;&amp;#x26;&lt;/code&gt;​ 和 &lt;code&gt;*&lt;/code&gt; 的真实含义&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;当前问题存在示例代码，可以前往&lt;a href=&quot;https://github.com/zxc7563598/go-lab/commit/410c209d6f96cf3ccea254b7cb0e98433af4b041&quot;&gt;GitHub查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;刚接触 Go 的时候，我会下意识用 PHP 的视角去理解 &lt;code&gt;&amp;#x26;&lt;/code&gt;​ 和 &lt;code&gt;*&lt;/code&gt;​，把它们当成“引用传递”的另一种写法。但在实际对比之后，我发现这两个符号并不是在讨论“怎么传参”，而是在明确区分一件更基础的事情：​&lt;strong&gt;当前操作的是值，还是值所在的位置&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;在 PHP 中，这层区分基本是被隐藏的。变量更像是“名字指向值”，至于值是否被复制、是否共享，通常由运行时决定。即使使用 &lt;code&gt;&amp;#x26;&lt;/code&gt;​，它也更偏向一种语义层面的开关，用来改变变量之间的绑定关系，而不是一个可以被单独拿出来传递、存储或返回的实体。某种程度上，PHP 的 &lt;code&gt;&amp;#x26;&lt;/code&gt; 已经把“位置”和“通过位置修改值”这两件事打包在一起了。&lt;/p&gt;
&lt;p&gt;Go 的选择正好相反。&lt;code&gt;&amp;#x26;&lt;/code&gt;​ 表达的是一个非常具体的动作：把某个值所在的位置本身当作一个值暴露出来；&lt;code&gt;*&lt;/code&gt; 则表示通过这个位置去访问或修改对应的数据。它们并不是在模拟 PHP 的引用语义，而是在显式引入“位置”这一概念，并要求调用方和使用方分别表态：一边决定是否交出位置，一边决定是否通过位置操作数据。&lt;/p&gt;
&lt;p&gt;从这个角度看，函数签名里的差异就变得很直接了。参数是值，还是指向值的位置，在定义阶段就已经确定，不需要依赖函数内部实现来判断是否会产生副作用。这一点在 struct 上尤其明显：Go 中的 struct 是值，而不是对象，只有显式地传递位置，修改才会作用到同一份数据上。&lt;/p&gt;
&lt;p&gt;因此，&lt;code&gt;&amp;#x26;&lt;/code&gt;​ 和 &lt;code&gt;*&lt;/code&gt; 更像是一种边界标记。写下它们，并不只是为了“能改到外面的变量”，而是在明确区分“数据的副本”和“数据本身”。和 PHP 那种由语言替你处理这些细节的方式相比，Go 更倾向于把选择提前，并且要求你把这个选择直接写进代码里。&lt;/p&gt;
&lt;h3&gt;11. 指针并不是“性能优化工具”&lt;/h3&gt;
&lt;p&gt;在一开始理解指针的时候，很容易把它和“性能优化”直接挂钩，尤其是从 PHP 这种对内存细节高度抽象的语言过来时，会下意识认为：传指针是不是就是为了少一次拷贝、快一点。&lt;/p&gt;
&lt;p&gt;但在实际对比之后，我更倾向于把指针理解为​&lt;strong&gt;语义工具，而不是性能工具&lt;/strong&gt;。它首先解决的并不是“快不快”，而是“这份数据是不是被共享、是不是允许被修改”。&lt;/p&gt;
&lt;p&gt;在 Go 里，是否使用指针，直接影响的是代码表达的含义。一个函数接收值，意味着它只能操作这份数据的副本；一个函数接收指针，意味着它明确依赖并可能修改某个已有的数据。这种区分本身就是 API 设计的一部分，而不是隐藏在实现细节里的性能技巧。&lt;/p&gt;
&lt;p&gt;当然，从结果上看，指针确实可能减少拷贝，尤其是在数据结构较大的情况下。但这是​&lt;strong&gt;使用指针之后自然产生的副作用&lt;/strong&gt;，而不是它存在的主要目的。Go 的编译器本身已经会在很多场景下帮你做逃逸分析和拷贝优化，如果只是为了“少拷一次”，往往并不需要手动引入指针。&lt;/p&gt;
&lt;p&gt;更重要的是，一旦把指针当成性能工具使用，代码的语义边界反而会变得模糊。一个函数之所以接收指针，究竟是因为它需要修改外部状态，还是只是为了“快一点”，从签名上已经无法判断。这种不确定性，往往比那点拷贝成本更昂贵。&lt;/p&gt;
&lt;p&gt;所以在 Go 里，是否使用指针，更像是在回答一个设计问题：​&lt;strong&gt;这是不是一份需要被共享和协同修改的数据&lt;/strong&gt;。性能因素当然存在，但它更适合出现在已经明确语义之后，而不是作为引入指针的第一理由。&lt;/p&gt;
&lt;h3&gt;12. nil 指针与零值的区别&lt;/h3&gt;
&lt;p&gt;在一开始接触 &lt;code&gt;nil&lt;/code&gt;​ 的时候，我很容易把它和“空值”划等号，甚至会下意识地把它当成某种“默认的零”。但在 Go 里，对比下来会发现，&lt;code&gt;nil&lt;/code&gt; 指针和零值并不在同一个层面上，它们描述的是两种不同的状态。&lt;/p&gt;
&lt;p&gt;零值描述的是“这个类型本身处在一个合法但未初始化的状态”。比如一个 &lt;code&gt;int&lt;/code&gt;​ 的零值是 &lt;code&gt;0&lt;/code&gt;​，一个 &lt;code&gt;struct&lt;/code&gt; 的零值是各字段的零值组合。它们都是完整、可用的值，可以被读取、传递，也可以参与计算。零值关注的是“值是什么”。&lt;/p&gt;
&lt;p&gt;​&lt;code&gt;nil&lt;/code&gt;​ 则更像是在回答另一个问题：这里有没有一个实际存在的东西。当一个指针是 &lt;code&gt;nil&lt;/code&gt;，并不是说它指向的值是“空的”，而是说它根本没有指向任何位置。它关注的不是值的内容，而是“指向关系是否存在”。&lt;/p&gt;
&lt;p&gt;这也是为什么同样是“什么都没初始化”，零值的 &lt;code&gt;struct&lt;/code&gt;​ 可以直接使用，而 &lt;code&gt;nil&lt;/code&gt; 指针却不能被解引用。前者是一个完整的值，只是内容处在默认状态；后者则缺少了最基本的前提——并不存在一个可以通过它访问的对象。&lt;/p&gt;
&lt;p&gt;如果把这种差异放回到 PHP 的语境中，会更容易看清楚。PHP 的 &lt;code&gt;null&lt;/code&gt;​ 更像是一种通用的“空”的表达：它既可能表示“没有值”，也可能表示“尚未初始化”或“没有结果”。同一个 &lt;code&gt;null&lt;/code&gt;，在不同场景下承担的是不同的语义，更多是一种语言层面的兜底状态。&lt;/p&gt;
&lt;p&gt;而 Go 并没有用 &lt;code&gt;nil&lt;/code&gt;​ 去覆盖所有“空”的情况。一个 &lt;code&gt;int&lt;/code&gt;​ 不可能是 &lt;code&gt;nil&lt;/code&gt;​，一个 &lt;code&gt;struct&lt;/code&gt;​ 也不可能是 &lt;code&gt;nil&lt;/code&gt;​，因为它们本身就是值，始终是存在的。只有那些需要依附于某个底层实体的类型，才会有“存在 / 不存在”这层状态，因此才会出现 &lt;code&gt;nil&lt;/code&gt;​。这使得 &lt;code&gt;nil&lt;/code&gt; 的含义相对单一，也更容易被约束。&lt;/p&gt;
&lt;p&gt;从这个角度看，&lt;code&gt;nil&lt;/code&gt;​ 本身并不是零值的替代品，而是一种额外的状态标记。是否允许出现 &lt;code&gt;nil&lt;/code&gt;​，本身就是 API 设计的一部分：返回零值，往往意味着“这是一个合法但内容处在默认状态的结果”；而返回 &lt;code&gt;nil&lt;/code&gt;，则更明确地表达“这里没有结果”或者“这个对象尚未存在”。&lt;/p&gt;
&lt;p&gt;因此，在 Go 里区分 &lt;code&gt;nil&lt;/code&gt;​ 指针和零值，关键不在于语法差异，而在于它们各自表达的语义边界。相比 PHP 用 &lt;code&gt;null&lt;/code&gt; 统一兜住各种“空”的情况，Go 更倾向于把“值是否存在”和“值的内容是什么”拆开表达，把选择和含义直接暴露在类型和签名中。&lt;/p&gt;
&lt;h3&gt;13. 指针在业务代码中的合理边界&lt;/h3&gt;
&lt;p&gt;它并不是用得越多越好，而是用来标记哪些数据具有共享和可变的属性。&lt;/p&gt;
&lt;p&gt;在大多数业务场景里，值语义本身已经足够。请求参数、配置快照、计算中间结果，这些数据更像是一次性输入或阶段性产物，用值来传递反而更清晰：函数拿到的是一份拷贝，能做的事情是受限的，也更容易推断行为。&lt;/p&gt;
&lt;p&gt;指针更适合出现在那些&lt;strong&gt;具有明确生命周期和身份的对象&lt;/strong&gt;上。比如聚合根、长期存在的上下文、需要被多处协同修改的状态。这里使用指针，并不是为了避免拷贝，而是在表达：这不是一份临时数据，而是一个被持续引用和演化的实体。&lt;/p&gt;
&lt;p&gt;从接口设计的角度看，指针往往意味着副作用是设计的一部分。如果一个函数接收指针，通常可以预期它会对外部状态产生影响；而只接收值的函数，则更接近于纯逻辑处理。这种区分一旦稳定下来，代码的可读性会比任何注释都强。&lt;/p&gt;
&lt;p&gt;同时，指针的边界也应该尽量收敛。越靠近业务边缘，比如 handler、service 层，对指针的使用就越需要谨慎。指针在这些层级一旦被随意传递，很容易让状态修改在调用链中扩散，最终变成难以追踪的隐式依赖。相反，把指针限制在领域内部或基础设施层，往往更容易控制其影响范围。&lt;/p&gt;
&lt;p&gt;所以在业务代码中，是否使用指针，更多是在回答一个设计问题：​&lt;strong&gt;这里是不是一个需要被共享、被持续修改的对象&lt;/strong&gt;。当这个问题的答案不明确时，优先选择值，通常会得到一个更稳定、更容易演进的结构。&lt;/p&gt;
&lt;h3&gt;14. Go 为什么不鼓励随意暴露指针&lt;/h3&gt;
&lt;p&gt;​&lt;strong&gt;Go 并不是反对指针本身，而是不鼓励它被随意暴露&lt;/strong&gt;。这种克制并不是出于安全限制，而更像是一种设计取向。&lt;/p&gt;
&lt;p&gt;一旦指针被暴露出去，暴露的其实并不只是一个数据访问方式，而是​&lt;strong&gt;对内部状态的直接操作权&lt;/strong&gt;。拿到指针的一方，不需要经过任何额外的约束，就可以修改其指向的数据，这会让原本清晰的状态边界变得模糊。数据“属于谁”、由谁负责维护，不再只保持在定义处，而是开始向调用方扩散。&lt;/p&gt;
&lt;p&gt;从 API 设计的角度看，指针会把实现细节向外泄漏。一个返回指针的函数，实际上是在告诉使用者：这里有一块可以被直接操作的内部数据。这种暴露一旦发生，后续的重构空间就会被压缩——内部结构是否还能调整、是否还能增加校验逻辑，都会受到限制。&lt;/p&gt;
&lt;p&gt;相比之下，返回值或只接收值参数，意味着调用方只能通过你定义好的入口与数据交互。修改行为被集中在有限的函数中，状态变化路径也更容易被追踪。这并不会减少灵活性，而是在用结构换取长期的可维护性。&lt;/p&gt;
&lt;p&gt;另外，指针的暴露还会影响代码的阅读方式。看到一个值，默认可以认为它是局部、受控的；看到一个指针，则需要额外思考它可能在什么地方被修改过。随着指针在调用链中不断传递，这种不确定性会迅速累积，最终让代码的理解成本超过它带来的便利。&lt;/p&gt;
&lt;p&gt;因此，Go 对指针的克制使用，更像是在强调一件事：​&lt;strong&gt;共享状态本身就是一种需要被谨慎对待的设计选择&lt;/strong&gt;。指针并不是被禁止的工具，但它更适合被限制在清晰的边界之内，而不是作为默认的数据暴露方式存在。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;四、slice：Go 中最“像魔法”的数据结构&lt;/h2&gt;
&lt;h3&gt;15. slice 的底层结构：指针、长度、容量&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;当前问题存在示例代码，可以前往&lt;a href=&quot;https://github.com/zxc7563598/go-lab/commit/e8ccf2ba09f7a754aafb365a00b19fa8ff8d80f6&quot;&gt;GitHub查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;一开始接触 slice 时，很容易从语法形式去理解它。&lt;/p&gt;
&lt;p&gt;​&lt;code&gt;int&lt;/code&gt;​、&lt;code&gt;string&lt;/code&gt;​ 是具体类型，那 &lt;code&gt;[]int&lt;/code&gt;​、&lt;code&gt;[]string&lt;/code&gt;​ 看起来就像是“可以装多个值的版本”（可变数组）。&lt;/p&gt;
&lt;p&gt;这种理解在使用层面基本成立，但在解释 slice 的一些行为时，总会出现不太连贯的地方，比如切出来的 slice 为什么会互相影响，&lt;code&gt;append&lt;/code&gt;​ 为什么一定要接收返回值，或者 &lt;code&gt;len&lt;/code&gt;​ 和 &lt;code&gt;cap&lt;/code&gt; 为什么会呈现出不同的变化节奏。&lt;/p&gt;
&lt;p&gt;把这些现象放在一起看，会逐渐意识到一个前提：&lt;strong&gt;slice 本身并不负责存放数据&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;在 Go 里，真正承载数据的是 &lt;strong&gt;array&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;array&lt;/strong&gt; 的内存是连续、一次性分配的，长度固定；而 slice 更像是对这段内存的一种描述。与其把 slice 理解成“可变数组”，不如把它看成是对某一段连续内存的引用说明。&lt;/p&gt;
&lt;p&gt;从实现角度看，slice 可以抽象成一个很小的结构，里面包含三类信息：&lt;code&gt;指向底层数组中某个位置的指针&lt;/code&gt;、&lt;code&gt;当前可以访问的长度&lt;/code&gt;，以及&lt;code&gt;从这个位置开始到底层数组末尾的容量&lt;/code&gt;。slice 自身并不拥有数据，它只是标记了从哪里开始、当前能用多少、以及理论上还能扩展到哪里。&lt;/p&gt;
&lt;p&gt;这个视角在切片操作中体现得非常直接。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;arr := [5]int{1, 2, 3, 4, 5}
s := arr[1:4]

// s: [2 3 4]
// len: 3
// cap: 4
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的 &lt;code&gt;1:4&lt;/code&gt; 描述的是下标区间，而不是具体内容。&lt;/p&gt;
&lt;p&gt;切片之后：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;​&lt;code&gt;s&lt;/code&gt;​ 的起点指向 &lt;code&gt;arr[1]&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;​&lt;code&gt;len(s)&lt;/code&gt;​ 为 &lt;code&gt;3&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;​&lt;code&gt;cap(s)&lt;/code&gt;​ 从 &lt;code&gt;arr[1]&lt;/code&gt; 一直延伸到数组末尾&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;slice 并没有复制任何数据，只是把指针向后挪了一格，并重新标注了可见范围和容量边界。对 slice 再进行切片，发生的事情也是类似的，只是在同一块底层数组上不断调整这些标记。&lt;/p&gt;
&lt;p&gt;这也解释了为什么在观察 &lt;code&gt;len&lt;/code&gt;​ 和 &lt;code&gt;cap&lt;/code&gt; 时，它们的变化往往不同步。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;s := []int{}
for i := 0; i &amp;#x3C; 6; i++ {
	s = append(s, i)
	fmt.Println(len(s), cap(s))
}
// 输出：
// 1 4
// 2 4
// 3 4
// 4 4
// 5 8
// 6 8
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在这类输出中，&lt;code&gt;len&lt;/code&gt;​ 会随着元素增加而线性增长，而 &lt;code&gt;cap&lt;/code&gt;​ 则会在一段时间内保持不变，然后突然变大。&lt;code&gt;len&lt;/code&gt;​ 描述的是当前已经使用的部分，而 &lt;code&gt;cap&lt;/code&gt; 描述的是底层数组还能提供的空间大小，它们关注的是两个不同的边界。&lt;/p&gt;
&lt;p&gt;当 &lt;code&gt;append&lt;/code&gt;​ 发现继续写入会超过当前容量时，就会为 slice 分配一块新的底层数组，并把已有数据拷贝过去。从这一刻开始，新的 slice 就已经不再和之前那块内存绑定在一起了。&lt;/p&gt;
&lt;p&gt;这也是为什么 &lt;code&gt;append&lt;/code&gt; 的结果需要被重新赋值：你拿到的，可能已经是一个指向不同内存区域的 slice。&lt;/p&gt;
&lt;p&gt;在此之前，如果多个 slice 是从同一个 array 或 slice 切出来的，那么它们很可能共享同一块底层数组。&lt;/p&gt;
&lt;p&gt;在容量范围内对其中一个 slice 的修改，本质上都是在操作同一段内存；&lt;/p&gt;
&lt;p&gt;只有当某一次扩容触发了重新分配，这种共享关系才会被打破。&lt;/p&gt;
&lt;p&gt;这样再看 slice，它的定位会变得清晰一些。它既不是数组本身，也不是一个独立的容器，而是一种对连续内存的访问方式。&lt;/p&gt;
&lt;p&gt;指针决定了起点，长度限定了当前可见的范围，容量则标记了还能向后延伸的边界。&lt;/p&gt;
&lt;h3&gt;16. slice ≠ array：为什么 append 会出问题&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;当前问题存在示例代码，可以前往&lt;a href=&quot;https://github.com/zxc7563598/go-lab/commit/e8ccf2ba09f7a754aafb365a00b19fa8ff8d80f6&quot;&gt;GitHub查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;在理解了 slice 的底层结构之后，再回头看一些 &lt;code&gt;append&lt;/code&gt;​ 的行为，就会发现问题并不出在 &lt;code&gt;append&lt;/code&gt; 本身，而是出在对 slice 语义的预期上。&lt;/p&gt;
&lt;p&gt;如果把 slice 当成一个“独立的、可变的数组”，那么很自然会认为：对一个 slice 的修改，不应该影响另一个看起来无关的 slice。但在 Go 里，这个前提并不成立。&lt;/p&gt;
&lt;p&gt;slice 和 array 的差异，首先体现在它们“值”的含义上。array 的值本身就包含了全部数据，而 slice 的值只是描述了一段数据的位置和范围。&lt;/p&gt;
&lt;p&gt;当 slice 被赋值、被切片、被传参时，被复制的只是这组描述信息，而不是底层的数据。&lt;/p&gt;
&lt;p&gt;这种差异在 &lt;code&gt;append&lt;/code&gt; 场景中会被放大。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;s := make([]int, 0, 5) // 预先分配 5 cap
s = append(s, 1, 2, 3) // len == 3，len &amp;#x3C; cap 不会重新分配内存

a := s[:2]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;此时，&lt;code&gt;s&lt;/code&gt;​ 和 &lt;code&gt;a&lt;/code&gt;​ 指向的是同一块底层数组，只是各自的 &lt;code&gt;len&lt;/code&gt;​ 不同。如果接下来对 &lt;code&gt;a&lt;/code&gt;​ 进行 &lt;code&gt;append&lt;/code&gt;​，并且追加的元素仍然落在 &lt;code&gt;cap&lt;/code&gt; 范围内：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;a = append(a, 4) // len == 4，len &amp;#x3C; cap 不会重新分配内存
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;那么这次写入实际上发生在那块共享的内存上。结果就是，&lt;code&gt;a&lt;/code&gt;​ 的变化同时体现在了 &lt;code&gt;s&lt;/code&gt; 上。从表面上看，这种行为容易让人产生“append 出问题了”的感觉，但从 slice 的定义来看，它只是如实地反映了底层内存的状态。&lt;/p&gt;
&lt;p&gt;只有当继续追加，使得 &lt;code&gt;a&lt;/code&gt; 的长度超过了当前容量：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;a = append(a, 5, 6) // len == 6，len &gt; cap 重新分配内存
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Go 才会为 &lt;code&gt;a&lt;/code&gt;​ 分配新的底层数组，并将原有数据拷贝过去。从这一刻开始，&lt;code&gt;a&lt;/code&gt;​ 和 &lt;code&gt;s&lt;/code&gt; 才真正指向了不同的内存区域，后续的修改也不再相互影响。&lt;/p&gt;
&lt;p&gt;对比之下，array 不会出现类似情况。array 的赋值和传递都会拷贝全部数据，本身就不存在多个“视图”指向同一份数据的可能。&lt;/p&gt;
&lt;p&gt;也正因为如此，如果下意识地用 array 的直觉去理解 slice，就很容易在 &lt;code&gt;append&lt;/code&gt; 这样的场景中产生偏差。&lt;/p&gt;
&lt;p&gt;这样再看“append 会出问题”这件事，问题的来源其实并不复杂：&lt;code&gt;append&lt;/code&gt; 只是遵循了 slice 的内存模型，而反直觉的地方，来自于对 slice 角色的误判。&lt;/p&gt;
&lt;p&gt;一旦接受 slice 只是视图而不是存储，这些行为就会显得更像是结构本身的自然结果。&lt;/p&gt;
&lt;h3&gt;17. 扩容带来的引用断裂问题&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;当前问题存在示例代码，可以前往&lt;a href=&quot;https://github.com/zxc7563598/go-lab/commit/e8ccf2ba09f7a754aafb365a00b19fa8ff8d80f6&quot;&gt;GitHub查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;前面已经反复提到，slice 本身只是三元信息的组合：指针、长度、容量。&lt;/p&gt;
&lt;p&gt;只要容量还没用尽，&lt;code&gt;append&lt;/code&gt; 只是把数据继续写进同一块底层数组里，多个 slice 之间共享内存这一事实不会改变。&lt;/p&gt;
&lt;p&gt;真正需要额外留意的，其实只有一种情况：​&lt;strong&gt;扩容发生的时候&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;一旦 &lt;code&gt;append&lt;/code&gt; 触发扩容，Go 会重新分配一块更大的连续内存，把原有数据整体复制过去，然后返回一个指向新内存的 slice。&lt;/p&gt;
&lt;p&gt;这个过程不会“通知”其他 slice，也不会修改它们的指针。结果就是：&lt;strong&gt;原本指向同一块底层数组的 slice，从这一刻开始，可能已经不再共享内存了&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;这种变化在代码层面几乎是无感的：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;a = append(a, x)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;变量名没变，类型没变，但 slice 所描述的那段内存已经变了。&lt;/p&gt;
&lt;p&gt;所谓的“引用断裂”，并不是某个 slice 出了问题，而是​&lt;strong&gt;共享关系在扩容这一刻自然结束了&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;如果把 slice 理解为“对一段连续内存的视图”，那么这个结果其实并不意外：连续内存无法原地变大，扩容只能搬迁；一旦搬迁，指针改变，共享关系也就随之结束。&lt;/p&gt;
&lt;h3&gt;18. slice 作为函数参数的常见误解&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;当前问题存在示例代码，可以前往&lt;a href=&quot;https://github.com/zxc7563598/go-lab/commit/e8ccf2ba09f7a754aafb365a00b19fa8ff8d80f6&quot;&gt;GitHub查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;在把 slice 作为函数参数时，最容易产生的误解，其实只有一个：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;以为 slice 传进去之后，就天然具备“引用语义”。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;表面上看，这种感觉并不奇怪。&lt;/p&gt;
&lt;p&gt;在函数里修改 slice 的元素，外部确实能看到变化：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func f(s []int) {
	s[0] = 100
}

a := []int{1, 2, 3}
f(a)
fmt.Println(a) // [100 2 3]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这很容易让人形成一种判断：&lt;strong&gt;slice 是“按引用传递”的&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;但这个结论只在一个前提下成立：&lt;strong&gt;函数内外的 slice 仍然指向同一块底层数组。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;一旦把 &lt;code&gt;append&lt;/code&gt; 放进来，情况就变了：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func f(s []int) {
	s = append(s, 4)
}

a := []int{1, 2, 3}
f(a)
fmt.Println(a) // [1 2 3]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里并不是 &lt;code&gt;append&lt;/code&gt; 没生效，而是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;​&lt;code&gt;a&lt;/code&gt; 传入函数时，只拷贝了一份 slice 结构&lt;/li&gt;
&lt;li&gt;​&lt;code&gt;append&lt;/code&gt; 可能触发扩容&lt;/li&gt;
&lt;li&gt;新的 slice 指向了新的底层数组&lt;/li&gt;
&lt;li&gt;外部的 &lt;code&gt;a&lt;/code&gt; 从头到尾都没有被重新赋值&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以这个行为并不矛盾，只是前后关注的层级不一样：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;修改元素，改的是底层数组&lt;/li&gt;
&lt;li&gt;​&lt;code&gt;append&lt;/code&gt;，改的是 slice 自身（指针、len、cap）&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;而函数参数传递的，始终只是 slice 这个值。&lt;/p&gt;
&lt;p&gt;从这个角度看，更准确的说法其实是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;slice 是值类型，但它的值里，包含了对底层数组的引用信息。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;这也解释了为什么有些代码“看起来能改到外面”，但一旦规模变大、触发扩容，行为就突然变了。&lt;/p&gt;
&lt;p&gt;所以在函数边界上，真正需要记住的并不多：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;函数拿到的是 slice 的一份拷贝&lt;/li&gt;
&lt;li&gt;是否影响外部，取决于是否仍然共享底层数组&lt;/li&gt;
&lt;li&gt;一旦扩容发生，共享关系自然结束&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;理解这一点之后，slice 作为参数的行为，其实就不再有什么特殊之处了。&lt;/p&gt;
&lt;h3&gt;19. slice 在并发场景下的风险&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;当前问题存在示例代码，可以前往&lt;a href=&quot;https://github.com/zxc7563598/go-lab/commit/e8ccf2ba09f7a754aafb365a00b19fa8ff8d80f6&quot;&gt;GitHub查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;这一块其实可以顺着前面的理解自然往下推，在并发场景里，slice 的“风险”并不是它有什么特殊规则，而是​&lt;strong&gt;它把共享内存这件事隐藏得太轻了&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;先说一个容易被忽略的事实：slice 本身是一个很小的值，但它描述的是一段真实存在的、连续的内存。&lt;/p&gt;
&lt;p&gt;当多个 goroutine 同时持有“看起来是不同的 slice”，但它们实际上指向同一块底层数组时，并发风险就已经成立了。&lt;/p&gt;
&lt;p&gt;比如这样一种情况：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;base := make([]int, 0, 10)

a := base[:5]
b := base[2:7]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;从代码层面看，&lt;code&gt;a&lt;/code&gt;​ 和 &lt;code&gt;b&lt;/code&gt; 是两个独立的变量；&lt;/p&gt;
&lt;p&gt;从内存层面看，它们的可见范围是重叠的。&lt;/p&gt;
&lt;p&gt;如果此时两个 goroutine 分别操作它们：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;go func() {
	a[0] = 100
}()

go func() {
	b[0] = 200
}()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里并不存在什么“slice 专属问题”，本质上就是​&lt;strong&gt;多个 goroutine 在无同步的情况下写同一块内存&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;危险之处在于：&lt;strong&gt;slice 的这种共享关系，往往不是显式写出来的，而是通过切片操作自然形成的&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;另一个更隐蔽的风险，来自 &lt;code&gt;append&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;如果多个 goroutine 对同一个 slice 进行 &lt;code&gt;append&lt;/code&gt;，问题并不只是“是否扩容”这么简单：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;go func() {
	s = append(s, 1)
}()

go func() {
	s = append(s, 2)
}()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里至少有几层不确定性：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;​&lt;code&gt;len&lt;/code&gt; 的更新不是原子的&lt;/li&gt;
&lt;li&gt;是否触发扩容，取决于时序&lt;/li&gt;
&lt;li&gt;一次扩容可能让某个 goroutine 拿到新的底层数组&lt;/li&gt;
&lt;li&gt;另一个 goroutine 仍然在操作旧的那一块&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;结果可能是数据丢失、覆盖，甚至直接触发 data race。&lt;/p&gt;
&lt;p&gt;而最容易踩坑的地方在于：
&lt;strong&gt;即使不发生扩容，也依然是非线程安全的。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;因为 &lt;code&gt;append&lt;/code&gt; 在修改 slice 时，至少会同时修改：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;底层数组中的元素&lt;/li&gt;
&lt;li&gt;slice 自身的 &lt;code&gt;len&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这两件事都不是并发安全的。&lt;/p&gt;
&lt;p&gt;所以在并发语境下，看待 slice 有一个比较稳妥的心智模型：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;slice 不是并发安全的容器&lt;/li&gt;
&lt;li&gt;共享 slice，本质上就是共享内存&lt;/li&gt;
&lt;li&gt;只要存在写操作，就必须有同步手段&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果需要在多个 goroutine 之间安全地使用 slice，通常只有几种选择：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;明确只读，不写&lt;/li&gt;
&lt;li&gt;在外层用 mutex 保护所有访问&lt;/li&gt;
&lt;li&gt;在 goroutine 之间传递 slice 的拷贝，而不是共享&lt;/li&gt;
&lt;li&gt;或者在设计上避免 slice 成为共享状态&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;理解了 slice 的底层结构之后，这些结论其实都不突兀。&lt;/p&gt;
&lt;p&gt;并发场景下的问题，并不是 slice “不可靠”，而是它对内存的描述能力太直接，而并发恰恰放大了这一点。&lt;/p&gt;
&lt;h2&gt;五、map：看起来简单，实则暗雷密布&lt;/h2&gt;
&lt;h3&gt;20. map 是引用类型，但不是并发安全&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;当前问题存在示例代码，可以前往&lt;a href=&quot;https://github.com/zxc7563598/go-lab/commit/e77e3a1bbe0b88e56c134a9d3faba6d456ad95b1&quot;&gt;GitHub查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;在 Go 里，&lt;code&gt;map[K]V&lt;/code&gt; 是用来做键值映射的类型。&lt;/p&gt;
&lt;p&gt;最常见的用法，大概就是这样：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;m := map[string]int{}
m[&quot;a&quot;] = 1
m[&quot;b&quot;] = 2

v := m[&quot;a&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;通过 key 读写 value，没有下标、没有顺序，也不关心元素在内存中的位置。&lt;/p&gt;
&lt;p&gt;从使用体验上看，它更像一个“随手可用的关联表”。&lt;/p&gt;
&lt;p&gt;而在把 map 用进实际代码之前，很容易先形成一个直觉判断：&lt;strong&gt;map 是引用类型。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这个判断来自它在函数间传递时的表现：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func f(m map[string]int) {
	m[&quot;x&quot;] = 100
}

a := map[string]int{}
f(a)
fmt.Println(a) // map[x:100]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;map 被作为参数传入函数，在函数里修改之后，外部能直接看到结果。&lt;/p&gt;
&lt;p&gt;这和 slice 修改元素时的行为非常接近，也很自然地让人把 map 理解为“引用传递”。&lt;/p&gt;
&lt;p&gt;但问题往往就出在这里。&lt;/p&gt;
&lt;p&gt;如果顺着这个理解继续往前走，很容易下意识地认为：&lt;strong&gt;既然 map 是引用类型，那在并发场景下，它的行为也应该是稳定、可预期的&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;事实恰好相反。&lt;/p&gt;
&lt;p&gt;map 虽然表现得像引用，但它​&lt;strong&gt;并不是并发安全的&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;而且这个限制是非常明确的：只要存在并发写操作，哪怕只有一个读，都是不允许的。&lt;/p&gt;
&lt;p&gt;从实现角度看，map 对应的是运行时维护的一张哈希表。&lt;/p&gt;
&lt;p&gt;一次看似简单的写入，背后可能涉及 bucket 的调整、元素移动，甚至扩容过程。&lt;/p&gt;
&lt;p&gt;这些操作都不是原子的，也没有为并发访问设计同步机制。&lt;/p&gt;
&lt;p&gt;所以，map 的定位其实很清晰：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;它在语义上是“引用式使用”的&lt;/li&gt;
&lt;li&gt;但在并发模型上，默认假设只有一个 goroutine 在操作&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这两点并不矛盾，只是很容易在直觉上被混在一起。&lt;/p&gt;
&lt;p&gt;把这一层想清楚之后，后面关于 map 并发 panic、为什么必须加锁、为什么会有 &lt;code&gt;sync.Map&lt;/code&gt;，其实都只是自然延伸而已。&lt;/p&gt;
&lt;h3&gt;21. nil map vs make(map)&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;当前问题存在示例代码，可以前往&lt;a href=&quot;https://github.com/zxc7563598/go-lab/commit/e77e3a1bbe0b88e56c134a9d3faba6d456ad95b1&quot;&gt;GitHub查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;在使用 map 时，很容易遇到两种看起来很接近的写法：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;var m1 map[string]int
m2 := make(map[string]int)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;从类型上看，它们都是 &lt;code&gt;map[string]int&lt;/code&gt;​，&lt;code&gt;len&lt;/code&gt; 也同样是 0：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;fmt.Println(len(m1)) // 0
fmt.Println(len(m2)) // 0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果只停在这里，很容易觉得它们只是两种等价的初始化方式。&lt;/p&gt;
&lt;p&gt;但实际使用中，很快就会发现它们的行为并不一样。&lt;/p&gt;
&lt;p&gt;先看 &lt;code&gt;nil map&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;​&lt;code&gt;var m1 map[string]int&lt;/code&gt;​ 声明了一个 map 类型的变量，但并没有为它分配任何底层哈希表。&lt;/p&gt;
&lt;p&gt;这个时候，&lt;code&gt;m1&lt;/code&gt;​ 的值是 &lt;code&gt;nil&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;对 &lt;code&gt;nil map&lt;/code&gt; 来说，有些操作是允许的：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;v := m1[&quot;a&quot;]      // 读
_, ok := m1[&quot;a&quot;]  // 判断是否存在
l := len(m1)      // len
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这些操作都不会 panic，结果也都很直观：读不到值，&lt;code&gt;ok&lt;/code&gt;​ 为 &lt;code&gt;false&lt;/code&gt;，长度为 0。&lt;/p&gt;
&lt;p&gt;但一旦尝试写入：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;m1[&quot;a&quot;] = 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;程序会直接 panic。&lt;/p&gt;
&lt;p&gt;原因并不复杂：写入 map 需要一个已经存在的哈希表，而 &lt;code&gt;nil map&lt;/code&gt; 并没有任何底层结构可以写。&lt;/p&gt;
&lt;p&gt;再看 &lt;code&gt;make(map)&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;m2 := make(map[string]int)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里发生的事情是：&lt;strong&gt;运行时为 map 分配并初始化了一张空的哈希表&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;从这一刻开始：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;可以安全地读&lt;/li&gt;
&lt;li&gt;可以安全地写&lt;/li&gt;
&lt;li&gt;可以不断插入新的 key-value&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以，两者之间真正的区别不在“是不是空”，而在于：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;​&lt;code&gt;nil map&lt;/code&gt;：类型存在，但底层结构不存在&lt;/li&gt;
&lt;li&gt;​&lt;code&gt;make(map)&lt;/code&gt;：类型存在，底层结构也已经准备好&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;把这一点和前面关于“map 是引用式使用”的结论放在一起，其实就很好理解了。&lt;/p&gt;
&lt;p&gt;map 这个值，本身就是一个指向运行时结构的引用；&lt;/p&gt;
&lt;p&gt;​&lt;code&gt;nil map&lt;/code&gt;，只是这个引用还没指向任何东西。&lt;/p&gt;
&lt;p&gt;从这个角度看，&lt;code&gt;nil map&lt;/code&gt; 并不是一个“特殊的空容器”，而更像是一个尚未初始化的状态。&lt;/p&gt;
&lt;p&gt;也正因为如此，实践中对 map 的态度往往会很明确：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;如果只是读，&lt;code&gt;nil map&lt;/code&gt; 完全可以接受&lt;/li&gt;
&lt;li&gt;如果需要写，就必须确保 map 已经通过 &lt;code&gt;make&lt;/code&gt; 初始化&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;22. map 在函数间传递的行为&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;当前问题存在示例代码，可以前往&lt;a href=&quot;https://github.com/zxc7563598/go-lab/commit/e77e3a1bbe0b88e56c134a9d3faba6d456ad95b1&quot;&gt;GitHub查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;在函数之间传递 map 时，最容易产生的直觉是：&lt;strong&gt;既然 map 是引用类型，那传来传去应该都指向同一个东西。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;从使用结果看，这个直觉往往是“对的”：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func f(m map[string]int) {
	m[&quot;a&quot;] = 1
}

func main() {
	m := make(map[string]int)
	f(m)
	fmt.Println(m) // map[a:1]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;函数里对 map 的修改，外部可以直接看到，这和 slice 修改元素、或者指针参数的表现非常接近。&lt;/p&gt;
&lt;p&gt;但如果只停在“引用类型”这个结论上，其实会漏掉一个很关键的层次：&lt;strong&gt;map 在函数间传递的，依然是一个值。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;只不过，这个值内部保存的是对运行时哈希表的引用。&lt;/p&gt;
&lt;p&gt;换个角度说：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;map 变量本身是值语义&lt;/li&gt;
&lt;li&gt;这个值里，指向的是一张共享的哈希表&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以，当你在函数里做的是&lt;strong&gt;修改表内容&lt;/strong&gt;时，外部自然能看到变化；&lt;/p&gt;
&lt;p&gt;但当你在函数里&lt;strong&gt;重新指向一张表&lt;/strong&gt;时，情况就完全不同了。&lt;/p&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func reset(m map[string]int) {
	m = make(map[string]int)
	m[&quot;a&quot;] = 1
}

func main() {
	m := map[string]int{&quot;x&quot;: 10}
	reset(m)
	fmt.Println(m) // map[x:10]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里并不是 &lt;code&gt;reset&lt;/code&gt; 没有生效，而是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;​&lt;code&gt;m&lt;/code&gt; 在函数参数处被拷贝了一份&lt;/li&gt;
&lt;li&gt;​&lt;code&gt;make(map)&lt;/code&gt;​ 只是让函数内的 &lt;code&gt;m&lt;/code&gt; 指向了一张新的哈希表&lt;/li&gt;
&lt;li&gt;外部的 &lt;code&gt;m&lt;/code&gt; 从头到尾都没有被重新赋值&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这个行为，和 slice 在函数中 &lt;code&gt;append&lt;/code&gt; 触发扩容时，其实非常相似。&lt;/p&gt;
&lt;p&gt;它们的共同点在于：&lt;strong&gt;函数能修改“被指向的内容”，但不能替换“调用方持有的那个引用”。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;从这个角度看，map 在函数间传递的规则其实非常一致：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;修改 key-value → 对外可见&lt;/li&gt;
&lt;li&gt;重新分配 map → 只影响函数内部&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以，如果函数的目标是“往已有 map 里填数据”，直接传 map 就足够；
但如果函数的目标是“构造一个新的 map 并交给外部使用”，那就应该：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;返回这个 map&lt;/li&gt;
&lt;li&gt;或者使用 &lt;code&gt;*map&lt;/code&gt;（但这在实践中很少推荐）&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;23. map 并发读写为什么会直接 panic&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;当前问题存在示例代码，可以前往&lt;a href=&quot;https://github.com/zxc7563598/go-lab/commit/e77e3a1bbe0b88e56c134a9d3faba6d456ad95b1&quot;&gt;GitHub查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;在使用 map 的过程中，有一个现象往往会让人印象很深：&lt;strong&gt;并发读写 map，不是数据错乱，而是直接 panic。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;比如这样的代码：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;m := make(map[string]int)

go func() {
	m[&quot;a&quot;] = 1
}()

go func() {
	_ = m[&quot;a&quot;]
}()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在很多语言里，这种情况可能只是读到不一致的数据；&lt;/p&gt;
&lt;p&gt;但在 Go 里，它很可能直接触发运行时 panic。&lt;/p&gt;
&lt;p&gt;一开始很容易把这个现象理解为：“Go 对 map 太严格了。”&lt;/p&gt;
&lt;p&gt;但如果结合 map 的实现方式来看，这个选择其实非常理性。&lt;/p&gt;
&lt;p&gt;map 底层是一张哈希表，而哈希表在写入过程中，并不是一个“稳定结构”。&lt;/p&gt;
&lt;p&gt;一次写操作，背后可能发生的事情包括：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;新 key 插入到 bucket&lt;/li&gt;
&lt;li&gt;bucket 内元素移动&lt;/li&gt;
&lt;li&gt;冲突链的调整&lt;/li&gt;
&lt;li&gt;甚至触发扩容和 rehash&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这些操作过程中，map 的内部状态会短暂地处于“中间态”。&lt;/p&gt;
&lt;p&gt;如果在这个时候，另一个 goroutine 进来读：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;它可能读到一个尚未完成调整的 bucket&lt;/li&gt;
&lt;li&gt;也可能遍历到一半被修改的数据结构&lt;/li&gt;
&lt;li&gt;最坏的情况，是破坏运行时对 map 结构完整性的假设&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;相比之下，Go 选择了一种非常直接的处理方式：&lt;strong&gt;一旦检测到并发读写，就直接终止程序。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这里的 panic，并不是为了“保护数据正确性”，而是为了​&lt;strong&gt;保护运行时本身不进入不可恢复的状态&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;换句话说，这并不是一个“业务级错误”，而是一个​&lt;strong&gt;内存安全层面的防线&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;从这个角度再看，就会发现：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;map 并发读写之所以 panic&lt;/li&gt;
&lt;li&gt;不是因为写得不安全&lt;/li&gt;
&lt;li&gt;而是因为这种行为在语义上根本没有被定义&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Go 并没有试图为 map 提供“模糊但能跑”的并发语义，而是明确要求：&lt;strong&gt;并发访问必须由使用者来同步。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这也和前面提到的设计取向是一致的：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;map 优先追求单线程下的性能和简洁&lt;/li&gt;
&lt;li&gt;并发语义通过 mutex、channel 或更高层抽象来解决&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;24. 使用 map 时的防御性写法&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;当前问题存在示例代码，可以前往&lt;a href=&quot;https://github.com/zxc7563598/go-lab/commit/e77e3a1bbe0b88e56c134a9d3faba6d456ad95b1&quot;&gt;GitHub查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;在理解了 map 的行为之后，再回头看“防御性写法”，它并不是为了让代码更复杂，而是为了​&lt;strong&gt;减少对隐含前提的依赖&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;最基础的一点，是对初始化状态保持明确。&lt;/p&gt;
&lt;p&gt;如果一个 map 在某个路径下可能会被写，那就尽量保证它在写之前已经完成初始化：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;if m == nil {
	m = make(map[string]int)
}
m[&quot;a&quot;] = 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这种写法本身并不优雅，但它明确地消除了 &lt;code&gt;nil map&lt;/code&gt; 带来的不确定性。&lt;/p&gt;
&lt;p&gt;在函数边界上，态度也可以更直接一些：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;如果函数的职责是“往 map 里填数据”，那就默认调用方已经完成初始化；&lt;/li&gt;
&lt;li&gt;如果函数需要“创建并返回一个 map”，那就直接返回，而不是试图在参数上隐式修改：&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func build() map[string]int {
	m := make(map[string]int)
	m[&quot;a&quot;] = 1
	return m
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样，map 的生命周期和所有权就非常清晰。&lt;/p&gt;
&lt;p&gt;在并发场景下，防御性写法反而更简单，也更严格：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;不要假设 map 在并发下“碰巧没问题”&lt;/li&gt;
&lt;li&gt;只要存在并发写，就必须有同步&lt;/li&gt;
&lt;li&gt;如果无法保证同步，就不要共享 map&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;最常见的方式，仍然是在外层使用 mutex，把所有访问收拢到同一个临界区：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;mu.Lock()
m[&quot;a&quot;] = 1
mu.Unlock()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;或者，在设计上直接避免 map 成为共享状态，比如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;每个 goroutine 持有自己的 map&lt;/li&gt;
&lt;li&gt;通过 channel 汇总结果&lt;/li&gt;
&lt;li&gt;或者在单个 goroutine 中集中处理 map&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;还有一个容易被忽略的点，是对 map 行为“不过度推断”。&lt;/p&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;不依赖遍历顺序&lt;/li&gt;
&lt;li&gt;不假设写入是原子的&lt;/li&gt;
&lt;li&gt;不在并发场景下混用读写而不加锁&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这些并不是 map 的“坑”，而是它明确不提供的保证。&lt;/p&gt;
&lt;p&gt;把这些原则放在一起看，会发现所谓的防御性，其实只是承认一件事：&lt;strong&gt;map 是一个高效、直接，但边界非常清晰的数据结构。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;只要不越过这些边界，它的行为就始终是稳定、可预期的。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;六、defer 与资源生命周期&lt;/h2&gt;
&lt;h3&gt;25. defer 的执行时机与栈模型&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;当前问题存在示例代码，可以前往&lt;a href=&quot;https://github.com/zxc7563598/go-lab/commit/a8b6d157f3103144429dbcdc023e18fbc7078516&quot;&gt;GitHub查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;第一次看到 &lt;code&gt;defer&lt;/code&gt; 的时候，其实很容易把它理解成一种“语法级的 finally”。&lt;/p&gt;
&lt;p&gt;它的用途也确实很直观：在函数里声明一段逻辑，但不立刻执行，而是等当前函数结束时再处理，常见的场景就是关闭文件、释放锁、回收连接之类的资源。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;f, _ := os.Open(&quot;test.txt&quot;)
defer f.Close()

// 这里做一些读写操作
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果只停留在这个层面，&lt;code&gt;defer&lt;/code&gt;​ 用起来几乎没有心理负担，甚至可以完全凭直觉去用：&lt;strong&gt;反正函数退出时它一定会执行&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;但当我继续往下看 defer 的执行规则时，发现 Go 对它的定义，其实比“函数结束时执行”要精确得多。&lt;/p&gt;
&lt;p&gt;在 Go 里，每一次执行到 &lt;code&gt;defer&lt;/code&gt;​ 语句，都会立刻发生一件事：对应的函数调用会被压入&lt;strong&gt;当前函数的一个 defer 栈&lt;/strong&gt;中。&lt;/p&gt;
&lt;p&gt;这里有几个细节是需要刻意注意的：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;是绑定在&lt;strong&gt;当前函数&lt;/strong&gt;上的&lt;/li&gt;
&lt;li&gt;使用的是&lt;strong&gt;栈结构&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;压栈行为发生在 &lt;strong&gt;执行到 defer 那一行的当下&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;真正的执行，只会发生在当前函数即将返回的时候，而且顺序是​&lt;strong&gt;后进先出（LIFO）&lt;/strong&gt; 。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func demo() {
	defer fmt.Println(&quot;A&quot;)
	defer fmt.Println(&quot;B&quot;)
	defer fmt.Println(&quot;C&quot;)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个函数返回时的输出顺序是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;C
B
A
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;并不是因为 Go 在“倒序执行 defer”，而是因为它从一开始就把 defer 当成一个栈来管理。&lt;/p&gt;
&lt;p&gt;理解这一点之后，我对 defer 的认知开始发生变化：它并不是简单地“延后执行一段代码”，而是&lt;strong&gt;提前登记一次调用，等待合适的时机统一执行&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;这一点在参数绑定上体现得尤其明显。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func demo() {
	x := 10
	defer fmt.Println(x)
	x = 20
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;最终输出的是 &lt;code&gt;10&lt;/code&gt;​，而不是 &lt;code&gt;20&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;原因并不复杂：在执行到 &lt;code&gt;defer fmt.Println(x)&lt;/code&gt;​ 这一行时，&lt;code&gt;fmt.Println(x)&lt;/code&gt;​ 这次调用就已经完整地被记录进 defer 栈了，&lt;code&gt;x&lt;/code&gt; 的值也在这一刻被确定下来，只是执行被延后了而已。&lt;/p&gt;
&lt;p&gt;所以从 defer 的角度看，更接近这样的模型：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;现在把这次调用压栈&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;将来在函数返回时按顺序执行&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;而不是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;先记住一段代码&lt;/li&gt;
&lt;li&gt;等函数结束时再“重新执行一次当时的上下文”&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;站在 PHP 的经验上看，很容易默认一种资源生命周期模型：作用域结束、对象析构、资源被自动回收。&lt;/p&gt;
&lt;p&gt;而 &lt;code&gt;defer&lt;/code&gt; 给出的，是一种更显式、也更可控的方式：&lt;/p&gt;
&lt;p&gt;它不依赖 GC 的触发时机，也不依赖对象何时被销毁，而是由开发者明确地指定：&lt;strong&gt;这个函数结束时，我要做哪几件事&lt;/strong&gt;。&lt;/p&gt;
&lt;h3&gt;26. defer + loop 的经典坑&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;当前问题存在示例代码，可以前往&lt;a href=&quot;https://github.com/zxc7563598/go-lab/commit/a8b6d157f3103144429dbcdc023e18fbc7078516&quot;&gt;GitHub查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;在前面已经理解了 &lt;code&gt;defer&lt;/code&gt;​ 是一个「在函数返回时统一出栈执行」的栈模型之后，再来看 &lt;code&gt;defer&lt;/code&gt;​ 和 &lt;code&gt;for&lt;/code&gt; 循环放在一起的场景，其实有必要再把逻辑拆得更细一点。&lt;/p&gt;
&lt;p&gt;先看这样一段代码：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;var i int
for i = 0; i &amp;#x3C; 3; i++ {
	defer fmt.Println(i)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里很容易把注意力放在「循环」和「后进先出」上，但如果只盯着顺序，其实反而会忽略真正关键的地方。&lt;/p&gt;
&lt;p&gt;这段代码的最终输出是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;2
1
0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个结果本身并不反直觉，也没有什么“坑”。&lt;/p&gt;
&lt;p&gt;原因在于：&lt;strong&gt;​&lt;code&gt;defer&lt;/code&gt;​&lt;/strong&gt;​ &lt;strong&gt;在入栈时，就已经把这一次函数调用所需的一切都确定下来了。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;对于 &lt;code&gt;defer fmt.Println(i)&lt;/code&gt; 来说：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;​&lt;code&gt;fmt.Println&lt;/code&gt; 是明确的函数&lt;/li&gt;
&lt;li&gt;​&lt;code&gt;i&lt;/code&gt; 是它的参数&lt;/li&gt;
&lt;li&gt;参数在 &lt;code&gt;defer&lt;/code&gt; 发生的那一刻就会被求值并拷贝&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以循环过程中实际发生的是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;第一次循环：&lt;code&gt;defer fmt.Println(0)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;第二次循环：&lt;code&gt;defer fmt.Println(1)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;第三次循环：&lt;code&gt;defer fmt.Println(2)&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;函数返回时，按后进先出的顺序执行，得到 &lt;code&gt;2 1 0&lt;/code&gt;，这一点和前面对 defer 栈模型的理解是完全一致的。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;真正容易产生误解的，其实是​&lt;strong&gt;另一种写法&lt;/strong&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;var i int
for i = 0; i &amp;#x3C; 3; i++ {
	defer func() {
		fmt.Println(i)
	}()
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这段代码的输出是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;3
3
3
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果只从“defer 是栈”来理解，这个结果就会显得有些突兀。但问题并不在 defer，而在于这里 defer 的​&lt;strong&gt;到底是什么&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;这一次，defer 的不是一个「已经绑定好参数的函数调用」，而是一个​&lt;strong&gt;匿名函数本身&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;这个匿名函数：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func() {
	fmt.Println(i)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;并没有参数，&lt;code&gt;i&lt;/code&gt; 来自外部作用域，是被闭包捕获的变量。&lt;/p&gt;
&lt;p&gt;而在 &lt;code&gt;for i := 0; i &amp;#x3C; 3; i++&lt;/code&gt;​ 这个循环里，&lt;code&gt;i&lt;/code&gt; 自始至终只有一个变量实例，每一轮只是不断修改它的值。&lt;/p&gt;
&lt;p&gt;于是整个过程变成了：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;循环中三次 defer，把三个“函数”压入栈中&lt;/li&gt;
&lt;li&gt;这三个函数内部引用的，都是&lt;strong&gt;同一个&lt;/strong&gt; &lt;strong&gt;​&lt;code&gt;i&lt;/code&gt;​&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;等函数真正开始执行时，循环早已结束&lt;/li&gt;
&lt;li&gt;此时 &lt;code&gt;i == 3&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;因此，无论出栈顺序如何，这三个 defer 最终看到的，都是同一个已经变成 &lt;code&gt;3&lt;/code&gt;​ 的 &lt;code&gt;i&lt;/code&gt;​，结果自然就是 &lt;code&gt;3 3 3&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;这里的问题，不是执行顺序，而是​&lt;strong&gt;变量绑定的时机&lt;/strong&gt;。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;如果希望在 defer 入栈时，就把「当时那一轮的值」固定下来，那么就需要显式地把它变成函数参数：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;var i int
for i = 0; i &amp;#x3C; 3; i++ {
	defer func(n int) {
		fmt.Println(n)
	}(i)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在这个版本里：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;每一轮循环都会创建一次新的函数调用&lt;/li&gt;
&lt;li&gt;当前的 &lt;code&gt;i&lt;/code&gt;​ 会被拷贝一份，作为参数 &lt;code&gt;n&lt;/code&gt; 传入&lt;/li&gt;
&lt;li&gt;defer 入栈的，是三次「参数已经确定好的调用」&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;于是 defer 栈中的内容等价于：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;​&lt;code&gt;fmt.Println(0)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;​&lt;code&gt;fmt.Println(1)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;​&lt;code&gt;fmt.Println(2)&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;函数返回时按后进先出执行，最终输出：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;2
1
0
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;p&gt;从这个角度再回头看，「defer + loop 的坑」其实并不是一个独立的规则，而是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;defer 是否在入栈时绑定了参数&lt;/li&gt;
&lt;li&gt;闭包是否捕获了外部变量&lt;/li&gt;
&lt;li&gt;循环变量在作用域内是否只有一个实例&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这几个语言层面的行为，在同一个场景里同时出现时，被集中地暴露了出来。&lt;/p&gt;
&lt;p&gt;而 defer，只是让这个问题变得更容易被注意到而已。&lt;/p&gt;
&lt;h3&gt;27. defer 在 Web 请求中的正确使用方式&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;当前问题存在示例代码，可以前往&lt;a href=&quot;https://github.com/zxc7563598/go-lab/commit/a8b6d157f3103144429dbcdc023e18fbc7078516&quot;&gt;GitHub查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;把 defer 放进 Web 请求里，其实一开始是很自然的一件事。&lt;/p&gt;
&lt;p&gt;在一个 HTTP handler 里，生命周期本身就非常清晰：一次请求进来，函数被调用；请求处理完成，函数返回。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func handler(w http.ResponseWriter, r *http.Request) {
	conn := getConn()
	defer conn.Close()

	// 使用 conn 处理请求
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;从 defer 的模型来看，这段代码几乎是“教科书级别地正确”：资源在函数中创建，在函数返回时释放，请求的生命周期和资源的生命周期是对齐的。&lt;/p&gt;
&lt;p&gt;问题并不出在这种写法上，而是出在：&lt;strong&gt;什么时候，defer 不再和一次请求的生命周期一一对应。&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;一个很容易被忽略的前提是：&lt;strong&gt;defer 只和当前函数的返回绑定，而和 HTTP 请求“本身”没有任何直接关系。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;只要函数没有返回，defer 就不会执行。&lt;/p&gt;
&lt;p&gt;这在大多数同步处理的 handler 里不是问题，但一旦请求处理中出现了下面几种情况，直觉就很容易失效：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;handler 内部启动了 goroutine&lt;/li&gt;
&lt;li&gt;资源被传递给了异步逻辑&lt;/li&gt;
&lt;li&gt;函数提前返回，但逻辑仍在继续执行&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;比如这样一种写法：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func handler(w http.ResponseWriter, r *http.Request) {
	conn := getConn()
	defer conn.Close()

	go func() {
		// 使用 conn 做一些异步处理
		doSomething(conn)
	}()

	w.WriteHeader(http.StatusOK)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;从代码表面看，defer 依然存在，&lt;code&gt;conn.Close()&lt;/code&gt; 也依然会被调用。&lt;/p&gt;
&lt;p&gt;但从生命周期的角度看，这里的资源已经&lt;strong&gt;脱离了请求处理函数的控制范围&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;handler 一返回，defer 立刻执行，而 goroutine 里的逻辑，可能才刚刚开始。&lt;/p&gt;
&lt;p&gt;在这种情况下，defer 并没有“失效”，失效的是&lt;strong&gt;把资源生命周期继续托付给当前函数&lt;/strong&gt;这个假设。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;这也是我在 Web 场景下重新理解 defer 的一个关键点：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;defer 只能管理“严格属于当前函数”的资源生命周期。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;一旦资源被交给了其他 goroutine、其他组件，那它的释放时机，就不应该再由当前函数的 defer 来决定。&lt;/p&gt;
&lt;p&gt;如果资源确实需要跨 goroutine 使用，那么就必须显式地把生命周期管理也一并交出去，比如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;由启动 goroutine 的那一方负责 Close&lt;/li&gt;
&lt;li&gt;或者在 goroutine 内部使用 defer&lt;/li&gt;
&lt;li&gt;或者通过 channel / context 明确结束信号&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;p&gt;另一个常见但更隐蔽的问题，是把 defer 放在​&lt;strong&gt;过大的函数作用域里&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;在 Web 服务中，一个 handler 往往不仅仅是“处理请求”，而是串联了：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;参数解析&lt;/li&gt;
&lt;li&gt;权限校验&lt;/li&gt;
&lt;li&gt;数据库操作&lt;/li&gt;
&lt;li&gt;外部服务调用&lt;/li&gt;
&lt;li&gt;响应构造&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果所有资源都在函数一开始创建，然后统一 defer 到函数结束才释放，从语义上看没问题，但从资源占用的角度看，生命周期可能被无意义地拉长了。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func handler(w http.ResponseWriter, r *http.Request) {
	db := getDB()
	defer db.Close()

	// 前面一大段并不需要 db 的逻辑
	validate(r)
	parseParams(r)

	// 很后面才真正使用 db
	query(db)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里 defer 的行为依然是完全正确的，但它也在提醒我：&lt;strong&gt;defer 不会帮你缩短生命周期，它只会忠实地等到函数返回。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;如果希望资源“用完就释放”，那就要么缩小作用域，要么主动拆分函数。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;所以在 Web 请求中，我后来给自己立了一条非常朴素的使用准则：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;如果资源的生命周期 === 当前 handler 函数
→ 用 defer，毫不犹豫&lt;/li&gt;
&lt;li&gt;如果资源会被异步逻辑继续使用
→ 不要在 handler 里 defer&lt;/li&gt;
&lt;li&gt;如果资源只在函数中间一小段逻辑里有效
→ 缩小作用域，而不是指望 defer 足够聪明&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;从这个角度看，defer 在 Web 场景下并不是“好不好用”的问题，而是你有没有把&lt;strong&gt;函数边界&lt;/strong&gt;当成资源生命周期边界的问题。&lt;/p&gt;
&lt;p&gt;而这恰好也是 Go 在很多地方反复强调的一件事：&lt;strong&gt;生命周期是显式的，责任是清晰的。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;28. Go 中资源释放为什么必须显式&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;当前问题存在示例代码，可以前往&lt;a href=&quot;https://github.com/zxc7563598/go-lab/commit/a8b6d157f3103144429dbcdc023e18fbc7078516&quot;&gt;GitHub查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;在把 defer 放进 Web 请求的语境里之后，我慢慢意识到一个问题：&lt;strong&gt;Go 里几乎所有重要的资源释放，都是显式的。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;文件要手动 &lt;code&gt;Close()&lt;/code&gt;​，数据库连接要手动 &lt;code&gt;Close()&lt;/code&gt;​，锁要手动 &lt;code&gt;Unlock()&lt;/code&gt;，&lt;/p&gt;
&lt;p&gt;哪怕有 GC，这些事情也都不会被自动完成。&lt;/p&gt;
&lt;p&gt;一开始很容易把这个现象理解成：Go 比较“底层”，或者“对开发者不够友好”。&lt;/p&gt;
&lt;p&gt;但当我把 defer、函数生命周期、Web 请求这些东西放在一起之后，才发现这并不是能力不足，而是一个非常明确的取舍。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;在 Go 里，GC 负责的事情其实被刻意限制得很窄：&lt;strong&gt;它只负责内存，不负责语义层面的资源。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;内存的回收，本质上是“对象是否还能被访问”的问题；&lt;/p&gt;
&lt;p&gt;而文件、连接、锁这类资源，是否应该被释放，往往并不是一个“是否还被引用”就能决定的事情。&lt;/p&gt;
&lt;p&gt;以数据库连接为例：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;连接对象还在被某个结构体持有&lt;/li&gt;
&lt;li&gt;但从业务语义上，这个请求已经结束&lt;/li&gt;
&lt;li&gt;这个连接其实已经“应该被归还”了&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;GC 并不知道这些语义，也不应该知道。&lt;/p&gt;
&lt;p&gt;如果把资源释放的责任交给 GC，那么释放时机就会变成一种​&lt;strong&gt;不可预测的副作用&lt;/strong&gt;，而不是程序行为的一部分。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;站在 PHP 的经验上，这种差异尤其明显。&lt;/p&gt;
&lt;p&gt;很多时候，我们并没有显式地关闭连接、文件或者句柄，因为：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;请求结束&lt;/li&gt;
&lt;li&gt;进程模型或 SAPI 回收资源&lt;/li&gt;
&lt;li&gt;脚本生命周期天然兜底&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这些机制并不是不存在，只是它们发生在​&lt;strong&gt;语言之外&lt;/strong&gt;，而不是语言本身的语义里。&lt;/p&gt;
&lt;p&gt;而 Go 的运行模型是长期运行的进程、并发的 goroutine、复用的资源池。&lt;/p&gt;
&lt;p&gt;在这种模型下，如果资源释放是“顺便发生的”，那么问题就会变得非常难以定位。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;这也是为什么 Go 选择了这样一种看起来有点“冷静”的方式：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;资源的获取是显式的&lt;/li&gt;
&lt;li&gt;资源的释放也是显式的&lt;/li&gt;
&lt;li&gt;生命周期绑定在函数边界上&lt;/li&gt;
&lt;li&gt;释放时机由开发者明确声明&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;​&lt;code&gt;defer&lt;/code&gt;​ 在这里并不是为了“帮你自动释放资源”，而是为了让你&lt;strong&gt;在逻辑上把释放这件事写在最合适的位置&lt;/strong&gt;，而执行时机又足够可靠。&lt;/p&gt;
&lt;p&gt;从这个角度看，&lt;code&gt;defer&lt;/code&gt; 更像是一种结构化承诺：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;我在这里获取了资源
我已经在同一个函数里声明了它的结束方式&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;p&gt;当我把“显式释放”这件事和 Web 请求重新对齐之后，这个选择就变得非常合理了。&lt;/p&gt;
&lt;p&gt;一次请求：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;什么时候开始&lt;/li&gt;
&lt;li&gt;什么时候结束&lt;/li&gt;
&lt;li&gt;用了哪些资源&lt;/li&gt;
&lt;li&gt;在哪里释放&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这些信息都应该是​&lt;strong&gt;从代码结构上就能读出来的&lt;/strong&gt;，而不是依赖运行时的某个隐含行为。&lt;/p&gt;
&lt;p&gt;这也是为什么在 Go 里，很多看起来有点“啰嗦”的写法，其实是在换取一件事：&lt;strong&gt;资源生命周期是可推导的。&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;七、error：Go 的“显式异常系统”&lt;/h2&gt;
&lt;h3&gt;29. error 是值，而不是异常&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;当前问题存在示例代码，可以前往&lt;a href=&quot;https://github.com/zxc7563598/go-lab/commit/7090278d1bd34fecfa491d0f06f039583a7ded83&quot;&gt;GitHub查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;在刚接触 Go 的时候，我对 &lt;code&gt;error&lt;/code&gt;​ 这个东西的第一反应，其实还是把它往「异常」上靠。名字叫 error，看起来又无处不在，很难不联想到 PHP 里的 &lt;code&gt;Exception&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;但真正开始写代码之后，会发现 Go 的 &lt;code&gt;error&lt;/code&gt;​ 从一开始就被设计成了一种​&lt;strong&gt;非常普通的存在&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;它不是关键字，也不是语法结构，只是一个接口：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;type error interface {
    Error() string
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这行定义本身就已经说明了很多问题。&lt;code&gt;error&lt;/code&gt; 并没有什么“特殊能力”，它既不能中断程序执行，也不能改变控制流。&lt;/p&gt;
&lt;p&gt;它唯一能做的事情，就是通过 &lt;code&gt;Error()&lt;/code&gt; 方法提供一段错误信息。&lt;/p&gt;
&lt;p&gt;在使用层面上，&lt;code&gt;error&lt;/code&gt; 通常会和函数返回值一起出现：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;result, err := doSomething()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这在 Go 里几乎是最常见的函数签名模式之一。函数要么返回一个有效结果，要么返回一个非 &lt;code&gt;nil&lt;/code&gt;​ 的 &lt;code&gt;error&lt;/code&gt;，调用方拿到这两个值之后，再决定接下来该怎么走。&lt;/p&gt;
&lt;p&gt;这个时候，如果还是用 PHP 的异常模型去理解，就会有一种明显的不适感。&lt;/p&gt;
&lt;p&gt;在 PHP 里，一旦抛出异常，代码的执行路径会立刻发生变化。&lt;/p&gt;
&lt;p&gt;当前函数后面的逻辑不再执行，调用栈自动回退，直到遇到 &lt;code&gt;catch&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;你不需要在每一层显式地处理它，语言会帮你把异常“抛”到一个合适的位置。&lt;/p&gt;
&lt;p&gt;而 Go 刻意没有提供这种能力。&lt;/p&gt;
&lt;p&gt;在 Go 里，错误本身​&lt;strong&gt;不具备“跳出去”的权力&lt;/strong&gt;​。函数返回了一个 &lt;code&gt;error&lt;/code&gt; 之后，程序依然沿着原来的路径往下执行，除非你显式地做出选择。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;v, err := doSomething()
// 这里不会自动发生任何事
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;你可以检查它，也可以忽略它，甚至可以什么都不做。语言层面不会替你判断“这个错误到底严不严重”。&lt;/p&gt;
&lt;p&gt;慢慢接受这一点之后，我开始意识到：在 Go 的设计里，&lt;code&gt;error&lt;/code&gt;​ 更像是​&lt;strong&gt;函数结果的一部分&lt;/strong&gt;，而不是“异常情况”。&lt;/p&gt;
&lt;p&gt;从这个角度看，很多事情就变得更好理解了。&lt;/p&gt;
&lt;p&gt;在 PHP 里，我们其实也经常会遇到类似的情况，只是表达方式不同。比如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;返回 &lt;code&gt;false&lt;/code&gt;​ 或 &lt;code&gt;null&lt;/code&gt;，再由调用方判断&lt;/li&gt;
&lt;li&gt;返回一个包含 &lt;code&gt;success / error&lt;/code&gt; 字段的结构&lt;/li&gt;
&lt;li&gt;或者直接抛异常，把处理权交给外层&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Go 只是把这种「成功 / 失败」的状态显式地放进了函数签名里，而且用的是类型系统，而不是控制流。&lt;/p&gt;
&lt;p&gt;这也解释了为什么 Go 社区里经常会强调一句话：​&lt;strong&gt;错误是值（error is a value）&lt;/strong&gt; 。&lt;/p&gt;
&lt;p&gt;它的含义并不是“错误不重要”，而是恰恰相反——错误是一个需要被看见、被传递、被讨论的结果，而不是一个被语言机制悄悄带走的分支。&lt;/p&gt;
&lt;p&gt;当我把 &lt;code&gt;error&lt;/code&gt; 当成值来看待，而不是异常时，心态上会发生一个很微妙的变化。关注点不再是「这里会不会 throw」，而是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;这个函数在失败时，会返回什么信息&lt;/li&gt;
&lt;li&gt;我在这一层，是否真的有能力处理这个错误&lt;/li&gt;
&lt;li&gt;如果处理不了，我该原样返回，还是补充一些上下文&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以说：Go 并不是“没有异常”，而是&lt;strong&gt;根本不打算用异常来解决错误处理这件事&lt;/strong&gt;。&lt;/p&gt;
&lt;h3&gt;30. ​&lt;code&gt;if err != nil&lt;/code&gt; 为什么是设计选择&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;当前问题存在示例代码，可以前往&lt;a href=&quot;https://github.com/zxc7563598/go-lab/commit/7090278d1bd34fecfa491d0f06f039583a7ded83&quot;&gt;GitHub查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;顺着前面对 &lt;code&gt;error&lt;/code&gt;​ 的理解，&lt;code&gt;if err != nil&lt;/code&gt; 这件事其实就不再是一个语法问题，而是一个非常明确的设计态度。&lt;/p&gt;
&lt;p&gt;一开始写 Go 的时候，我对这一行是有点抗拒的。几乎每调用一次函数，就要紧跟着写一行 &lt;code&gt;if err != nil&lt;/code&gt;，代码看起来重复，又不够“优雅”。&lt;/p&gt;
&lt;p&gt;从 PHP 的视角看，这些本来是可以被 &lt;code&gt;try / catch&lt;/code&gt; 包起来、一次性处理掉的东西。&lt;/p&gt;
&lt;p&gt;但后来我慢慢意识到，这种“重复”，并不是 Go 没能力抽象，而是​&lt;strong&gt;刻意不帮你抽象掉&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;如果错误是值，那么它就必须像其他返回值一样，被显式地检查。&lt;code&gt;if err != nil&lt;/code&gt;​ 本质上是在逼你在这一行代码上做出一个决定：​&lt;strong&gt;你到底认不认这个错误的存在&lt;/strong&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;v, err := doSomething()
if err != nil {
    return err
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这一段代码看起来很机械，但它有一个非常直接的效果：在阅读代码的时候，我不用去脑补“这里可能会抛异常”，也不用在脑海里维护一条隐形的异常路径。错误处理和正常逻辑，是在同一个时间、同一个位置被展开的。&lt;/p&gt;
&lt;p&gt;这和异常模型的一个核心差异在于：异常往往是&lt;strong&gt;延迟理解的&lt;/strong&gt;。你在读当前函数的时候，很难立刻知道：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;哪些函数可能抛异常&lt;/li&gt;
&lt;li&gt;这个异常会被谁接住&lt;/li&gt;
&lt;li&gt;中间有没有被吞掉或转换&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;而 &lt;code&gt;if err != nil&lt;/code&gt;​ 是&lt;strong&gt;即时可见的&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;错误有没有被处理、是被忽略、被记录，还是被直接向上返回，全部都写在当前函数里。&lt;/p&gt;
&lt;p&gt;从这个角度看，Go 并不是在追求“少写代码”，而是在追求一种更低的认知负担。它把复杂度摊开了，而不是藏起来。&lt;/p&gt;
&lt;p&gt;另外一个让我逐渐接受这个设计的点，是它对“层级责任”的划分非常清晰。&lt;/p&gt;
&lt;p&gt;在 PHP 里，用异常很容易不自觉地写出一种代码结构：底层随意 &lt;code&gt;throw&lt;/code&gt;​，上层统一 &lt;code&gt;catch&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;这在很多时候是合理的，但也很容易演变成“所有错误都在最外层兜底”，中间层反而对错误语义变得模糊。&lt;/p&gt;
&lt;p&gt;而在 Go 里，每一层都要直面 &lt;code&gt;err&lt;/code&gt;，你必须在这一层明确回答一个问题：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;这个错误我能处理吗？&lt;/li&gt;
&lt;li&gt;如果不能，我是否要补充信息再往上抛？&lt;/li&gt;
&lt;li&gt;还是应该在这里转成另一个错误？&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;​&lt;code&gt;if err != nil&lt;/code&gt;​ 的重复，其实是在不断提醒你：​&lt;strong&gt;错误处理是业务逻辑的一部分，而不是附加逻辑&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;甚至从代码结构上看，这种写法也在引导一种固定的节奏：先处理错误，再处理正常路径。很多 Go 代码都会把错误判断放在函数前半段，形成一种“早返回”的形态。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;if err != nil {
    return err
}

// 后面可以默认假设一切正常
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这种结构让正常逻辑尽量少嵌套，也减少了在脑子里同时维护多种执行分支的负担。&lt;/p&gt;
&lt;p&gt;所以后来再回头看，&lt;code&gt;if err != nil&lt;/code&gt;​ 并不是 Go 没有更“高级”的错误机制，而是它选择了一种&lt;strong&gt;最直白、最难被忽略&lt;/strong&gt;的方式。&lt;/p&gt;
&lt;p&gt;它牺牲的是代码的简洁感，换来的是错误处理的可见性和确定性。&lt;/p&gt;
&lt;h3&gt;31. 错误向上传递的最佳实践&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;当前问题存在示例代码，可以前往&lt;a href=&quot;https://github.com/zxc7563598/go-lab/commit/7090278d1bd34fecfa491d0f06f039583a7ded83&quot;&gt;GitHub查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;在接受了 &lt;code&gt;error&lt;/code&gt;​ 是值、&lt;code&gt;if err != nil&lt;/code&gt;​ 是一种刻意设计之后，下一个绕不开的问题其实是：&lt;strong&gt;那这些错误到底应该怎么往上交？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;刚开始写 Go 的时候，我对“向上传递”这件事的理解其实非常简单，甚至有点机械：底层返回 &lt;code&gt;err&lt;/code&gt;​，中间层原样 &lt;code&gt;return err&lt;/code&gt;，最外层统一处理。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;if err != nil {
    return err
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这当然是对的，但写多了之后，会发现一个问题：&lt;strong&gt;错误虽然被传上去了，但信息并没有一起传上去。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;等错误真的冒到最外层时，往往只剩下一句很底层、很抽象的描述，比如“not found”“invalid argument”。&lt;/p&gt;
&lt;p&gt;这时候再回头看调用链，反而要花时间去猜：&lt;strong&gt;这个错误是在哪一层发生的？当时在做什么？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;后来我慢慢意识到，Go 里所谓的“向上传递”，并不是单纯的“往上扔”，而是一个​&lt;strong&gt;逐层补充语义的过程&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;一个比较稳定的判断标准是：&lt;strong&gt;这一层如果无法处理错误，那它至少应该让错误在离开这一层之前，变得更好理解。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;最常见、也最安全的做法，其实是“原样返回 + 补充上下文”。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;if err != nil {
    return fmt.Errorf(&quot;load user config failed: %w&quot;, err)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里做的事情并不复杂，只是把“当前这层在做什么”这件事，和原始错误绑在了一起。&lt;/p&gt;
&lt;p&gt;等错误真的被打印或记录时，调用路径会自然地浮现出来。&lt;/p&gt;
&lt;p&gt;相反，有几种做法在一开始我也写过，但后来会尽量避免。&lt;/p&gt;
&lt;p&gt;比如在中间层直接“吃掉”错误，只返回一个新的、看起来更抽象的错误：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;return errors.New(&quot;something went wrong&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样写虽然干净，但实际上切断了错误的来源。&lt;/p&gt;
&lt;p&gt;一旦线上出问题，除了复现，很难再靠错误本身定位。&lt;/p&gt;
&lt;p&gt;另一种极端，是在每一层都急着“处理”错误，比如打印日志、统计、甚至直接 &lt;code&gt;panic&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;这会导致一个结果：同一个错误在不同层被反复处理，责任边界变得模糊。&lt;/p&gt;
&lt;p&gt;慢慢地我给自己形成了一个比较清晰的分工习惯：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;​&lt;strong&gt;底层&lt;/strong&gt;：负责返回“事实性的错误”，尽量准确描述发生了什么&lt;/li&gt;
&lt;li&gt;​&lt;strong&gt;中间层&lt;/strong&gt;：如果处理不了，就补充“语境”，再向上返回&lt;/li&gt;
&lt;li&gt;​&lt;strong&gt;顶层 / 边界层&lt;/strong&gt;：决定如何对外呈现（日志、返回值、HTTP 状态码等）&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;在这个结构下，“向上传递”不再是消极的甩锅，而是一种有意识的协作。&lt;/p&gt;
&lt;p&gt;还有一个对我帮助很大的点，是尽量避免用错误来承载“流程控制”。&lt;/p&gt;
&lt;p&gt;比如把“查不到数据”当成异常错误一路往上抛，最后在最外层再去判断。这在 Go 里通常会让代码读起来很拧巴。&lt;/p&gt;
&lt;p&gt;更自然的方式，反而是让函数签名本身表达清楚语义，比如：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;user, err := findUser(id)
if err != nil {
    return nil, err
}
if user == nil {
    // 这是业务分支，而不是系统错误
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;当错误只用于“真正的失败情况”，而不是“常见分支”，向上传递这件事才不会变形。&lt;/p&gt;
&lt;p&gt;写到这里我才发现，Go 对错误向上传递的“最佳实践”，并没有什么神秘技巧。&lt;/p&gt;
&lt;p&gt;它只是反复在提醒你：&lt;strong&gt;错误不是用来丢掉的，也不是用来滥用的，而是用来逐层说明发生了什么。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;32. panic、recover 的合理使用场景&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;当前问题存在示例代码，可以前往&lt;a href=&quot;https://github.com/zxc7563598/go-lab/commit/7090278d1bd34fecfa491d0f06f039583a7ded83&quot;&gt;GitHub查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;在理解了 Go 日常错误处理的方式之后，&lt;code&gt;panic&lt;/code&gt;​ 和 &lt;code&gt;recover&lt;/code&gt; 反而会显得有点“格格不入”。&lt;/p&gt;
&lt;p&gt;一边是被反复强调的 &lt;code&gt;error&lt;/code&gt;​、&lt;code&gt;if err != nil&lt;/code&gt;​、向上传递，另一边却突然冒出来一套看起来很像异常的机制，很容易让人产生一个误解：&lt;strong&gt;那我是不是也可以把 panic 当异常用？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;至少对我来说，这是一个需要刻意纠正的想法。&lt;/p&gt;
&lt;p&gt;从行为上看，&lt;code&gt;panic&lt;/code&gt;​ 确实会中断当前执行流程，开始回退调用栈，如果没人 &lt;code&gt;recover&lt;/code&gt;，程序就直接崩掉。&lt;/p&gt;
&lt;p&gt;这一点和 PHP 里的异常非常像。但差别在于，Go 并没有把它设计成“日常错误处理”的一部分。&lt;/p&gt;
&lt;p&gt;更准确地说，&lt;code&gt;panic&lt;/code&gt;​ 面向的不是“失败的业务情况”，而是：&lt;strong&gt;程序已经进入了不该存在的状态&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;这也是我后来判断要不要用 &lt;code&gt;panic&lt;/code&gt;​ 的一个核心标准：&lt;strong&gt;这个错误是不是意味着程序的假设已经被打破了？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;明明已经校验过的数据，却出现了不可能的值&lt;/li&gt;
&lt;li&gt;内部逻辑出现了明显的程序错误&lt;/li&gt;
&lt;li&gt;初始化阶段的关键配置缺失，程序根本不可能继续跑&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;在这些场景里，继续返回 &lt;code&gt;error&lt;/code&gt; 往上交，反而会让代码变得很奇怪。&lt;/p&gt;
&lt;p&gt;因为调用方即使“收到了错误”，也并没有什么合理的补救方式。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;if cfg == nil {
    panic(&quot;config must not be nil&quot;)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这种 panic 本质上是在说：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;不是你用错了这个函数，而是我这个程序已经写错了。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;​&lt;code&gt;recover&lt;/code&gt;​ 的存在，反而是为了让 &lt;code&gt;panic&lt;/code&gt; 不至于把一切都拉着一起死。&lt;/p&gt;
&lt;p&gt;但它的使用场景，其实比我一开始想象的要窄得多。&lt;/p&gt;
&lt;p&gt;​&lt;code&gt;recover&lt;/code&gt;​ 只能在 &lt;code&gt;defer&lt;/code&gt; 里生效，这本身就已经在限制它的使用方式了。&lt;/p&gt;
&lt;p&gt;它并不是让你在任何地方随意“抓 panic”，而更像是一种&lt;strong&gt;边界保护机制&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;一个我后来觉得比较合理的使用位置，通常是在“最外层边界”：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;HTTP 服务的请求入口&lt;/li&gt;
&lt;li&gt;goroutine 的启动封装&lt;/li&gt;
&lt;li&gt;框架级的调度入口&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;在这些地方，用 &lt;code&gt;recover&lt;/code&gt; 做一层兜底，可以防止单个请求或任务因为 panic 把整个进程带崩。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;defer func() {
    if r := recover(); r != nil {
        // 记录日志，返回 500
    }
}()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的目标并不是“把 panic 转成普通错误继续用”，而是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;记录足够的信息&lt;/li&gt;
&lt;li&gt;保证系统还能继续服务&lt;/li&gt;
&lt;li&gt;给开发者一个明确的信号：这里发生了不该发生的事&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;我后来会尽量避免在业务逻辑中显式调用 &lt;code&gt;recover&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;一旦在中间层开始捕获 panic，就很容易把程序错误伪装成业务错误，反而延迟问题暴露。&lt;/p&gt;
&lt;p&gt;所以在 Go 里，&lt;code&gt;panic / recover&lt;/code&gt;​ 更像是一条&lt;strong&gt;安全网&lt;/strong&gt;，而不是一条“备用错误通道”。&lt;/p&gt;
&lt;p&gt;它存在的意义，不是为了替代 &lt;code&gt;error&lt;/code&gt;，而是为了应对那些本不该出现、却一旦出现就必须被立刻注意到的问题。&lt;/p&gt;
&lt;p&gt;把它们放在这个位置上看待时，就不会再纠结“什么时候该用 panic，什么时候该用 error”这种二选一的问题了。&lt;/p&gt;
&lt;p&gt;绝大多数情况下，错误都只是错误；而只有在极少数情况下，才是 panic。&lt;/p&gt;
&lt;h3&gt;33. 错误包装（wrap）与错误定位&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;当前问题存在示例代码，可以前往&lt;a href=&quot;https://github.com/zxc7563598/go-lab/commit/7090278d1bd34fecfa491d0f06f039583a7ded83&quot;&gt;GitHub查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;在错误一路向上传递的过程中，有一个问题其实迟早会碰到：&lt;strong&gt;当错误真的被打出来的时候，我还能不能看懂它是从哪来的？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;如果只是简单地 &lt;code&gt;return err&lt;/code&gt;，那错误当然是完整的，但它通常只包含最底层的一小段信息。&lt;/p&gt;
&lt;p&gt;如果每一层都重新 &lt;code&gt;errors.New&lt;/code&gt; 一次，错误看起来倒是“干净”了，却完全失去了来源。&lt;/p&gt;
&lt;p&gt;错误包装（wrap）这件事，正好卡在这两者之间。&lt;/p&gt;
&lt;p&gt;Go 在 1.13 之后，把这件事变成了一种正式的、被语言支持的模式。最直观的体现，就是 &lt;code&gt;%w&lt;/code&gt; 这个占位符。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;if err != nil {
    return fmt.Errorf(&quot;open config file failed: %w&quot;, err)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这行代码表面上只是拼了一段字符串，但语义上发生了一件很关键的事：&lt;strong&gt;新的错误并没有覆盖旧的错误，而是把它包了进去。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;于是错误开始有了“层级”。&lt;/p&gt;
&lt;p&gt;当错误一路往上传的时候，每一层都只做一件很小的事：补一句「我当时在干什么」。&lt;/p&gt;
&lt;p&gt;等这个错误最终被打印出来时，你往往能看到一条非常符合调用顺序的描述链。&lt;/p&gt;
&lt;p&gt;这种感觉和异常的 stack trace 有点像，但它是&lt;strong&gt;显式构建出来的&lt;/strong&gt;，而不是运行时偷偷帮你收集的。&lt;/p&gt;
&lt;p&gt;更重要的是，包装并不只是为了“好看”。&lt;/p&gt;
&lt;p&gt;一旦错误是通过 &lt;code&gt;%w&lt;/code&gt; 包起来的，它在语义上仍然是“原来的那个错误”。&lt;/p&gt;
&lt;p&gt;这意味着你在上层依然可以判断错误的本质，而不是被字符串绑死。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;if errors.Is(err, fs.ErrNotExist) {
    // 文件不存在
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;哪怕这个错误已经被包了好几层，只要中间没有被打断，&lt;code&gt;errors.Is&lt;/code&gt; 都还能沿着错误链往里找。&lt;/p&gt;
&lt;p&gt;这点对我来说是一个很大的转变。&lt;/p&gt;
&lt;p&gt;在 PHP 里，异常的类型判断往往依赖 class 继承关系；而在 Go 里，错误定位更多是通过“语义关系”而不是“类型层级”。&lt;/p&gt;
&lt;p&gt;同样的，还有 &lt;code&gt;errors.As&lt;/code&gt;，可以用来判断错误链中是否存在某一类错误，并把它取出来。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;var pathErr *fs.PathError
if errors.As(err, &amp;#x26;pathErr) {
    // 可以访问更具体的错误信息
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;到这里我才意识到，Go 的错误包装并不是在补一个“异常系统”，而是在构建一条​&lt;strong&gt;可追溯、可判断、可组合的错误链&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;当然，这里也有一些很容易踩的点。&lt;/p&gt;
&lt;p&gt;比如在中间层用 &lt;code&gt;fmt.Errorf&lt;/code&gt;​，却忘了用 &lt;code&gt;%w&lt;/code&gt;​，而是直接 &lt;code&gt;%v&lt;/code&gt; 或字符串拼接。&lt;/p&gt;
&lt;p&gt;这样一来，错误在这一层就被“截断”了，上层再也无法判断它的真实来源。&lt;/p&gt;
&lt;p&gt;还有一种情况，是过度包装。每一层都加一大段说明，最终的错误信息反而变得冗长、重复，失去了重点。&lt;/p&gt;
&lt;p&gt;我后来比较倾向的做法是：只在“语义发生变化”的地方包装错误。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;从 IO 层进入业务层&lt;/li&gt;
&lt;li&gt;从业务层进入接口层&lt;/li&gt;
&lt;li&gt;从内部模块进入对外边界&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这些地方的错误含义，对上层来说确实发生了变化，加一层上下文是有价值的。&lt;/p&gt;
&lt;p&gt;等把这一整套连起来再回头看，会发现 Go 在错误处理上的态度其实一直很一致：不自动做决定、不隐藏信息，也不强迫你遵循某种宏大的模式。&lt;/p&gt;
&lt;p&gt;它只是给了你一些很基础的工具，然后把“错误该长成什么样子”的责任，交还给了代码本身。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;八、Go 的并发模型：从根上和 PHP 不一样&lt;/h2&gt;
&lt;h3&gt;34. goroutine 不是线程&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;当前问题存在示例代码，可以前往&lt;a href=&quot;https://github.com/zxc7563598/go-lab/commit/bc4debd69a2e822ace81db93ff161ba6be671495&quot;&gt;GitHub查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;刚接触 goroutine 的时候，我几乎是本能地把它理解成「更轻量的线程」。这种理解在一开始并不会立刻出问题，但它会在后面不断制造偏差。&lt;/p&gt;
&lt;p&gt;后来我意识到，问题并不在于“轻不轻”，而在于 &lt;strong&gt;goroutine 根本就不是线程&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;在 PHP 的世界里，并发通常意味着直接使用操作系统提供的能力：多进程，或者线程。&lt;/p&gt;
&lt;p&gt;这些并发单元本身就是 OS 资源，创建和切换都发生在内核态，对应的是清晰、可感知的成本。&lt;/p&gt;
&lt;p&gt;所以不管是 PHP-FPM 的 worker，还是显式的线程模型，本质上都在和操作系统打交道。&lt;/p&gt;
&lt;p&gt;而 goroutine 完全不在这个层面。&lt;/p&gt;
&lt;p&gt;它并不是操作系统的执行单元，而是 &lt;strong&gt;由 Go runtime 管理的一段执行逻辑&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;操作系统只看到少量线程在运行 Go 程序，但这些线程内部真正被调度、被切换的，其实是一批 goroutine。&lt;/p&gt;
&lt;p&gt;至于某个 goroutine 此刻跑在哪个线程上，操作系统并不知道，Go 程序本身也不需要知道。&lt;/p&gt;
&lt;p&gt;这一点会直接改变对并发的直觉。&lt;/p&gt;
&lt;p&gt;在 OS 线程模型下，一个线程通常意味着：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;一个固定大小的栈（往往是 MB 级别）&lt;/li&gt;
&lt;li&gt;创建和销毁需要内核参与&lt;/li&gt;
&lt;li&gt;上下文切换成本不可忽略&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;而 goroutine 的特性更像是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;初始栈非常小（只在需要时才增长）&lt;/li&gt;
&lt;li&gt;调度和切换发生在用户态&lt;/li&gt;
&lt;li&gt;不和某个线程绑定，可以被 runtime 迁移&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以，把 goroutine 理解成「轻量线程」其实是不够准确的。&lt;/p&gt;
&lt;p&gt;更贴切的说法是：&lt;strong&gt;它是 Go 语言层面提供的一种并发执行结构&lt;/strong&gt;，而不是系统层面的执行单元。&lt;/p&gt;
&lt;p&gt;如果强行用 PHP 的经验去类比，线程更像是一个 PHP-FPM worker，而 goroutine 更像是 worker 里的某一段执行流。&lt;/p&gt;
&lt;p&gt;这个类比只能帮助理解“为什么数量可以很多”，却不能用来解释行为差异。&lt;/p&gt;
&lt;p&gt;因为在 PHP 里，请求和执行上下文的关系是稳定的；&lt;/p&gt;
&lt;p&gt;而在 Go 里，goroutine 只是 runtime 调度的对象，它随时可能被挂起、恢复，甚至换一个线程继续跑。&lt;/p&gt;
&lt;p&gt;这也是一个很重要的认知转折点：&lt;/p&gt;
&lt;p&gt;Go 并不是在“更高效地使用线程”，而是 &lt;strong&gt;把并发这件事从操作系统层面，收回到了运行时和语言层面&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;一旦接受了这个前提，后面再去看调度模型、channel 这些设计，就不太容易陷入“它为什么要这么复杂”的困惑，而更像是在理解一套自洽的取舍。&lt;/p&gt;
&lt;h3&gt;35. GMP 调度模型的直觉理解&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;当前问题存在示例代码，可以前往&lt;a href=&quot;https://github.com/zxc7563598/go-lab/commit/bc4debd69a2e822ace81db93ff161ba6be671495&quot;&gt;GitHub查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;既然 goroutine 不是线程，那一个很自然的问题就会冒出来：这些 goroutine 到底是靠什么在跑？&lt;/p&gt;
&lt;p&gt;答案其实不复杂，但一开始很容易被名词带偏。&lt;/p&gt;
&lt;p&gt;Go 运行时真正关心的，其实只有三样东西：​&lt;strong&gt;要跑的任务、真正能跑代码的执行单元、以及把两者对接起来的调度机制&lt;/strong&gt;。GMP 模型就是围绕这三件事展开的。&lt;/p&gt;
&lt;p&gt;在这个模型里，G（goroutine）本身只是“一段可以被执行的逻辑”，它不具备执行能力；&lt;/p&gt;
&lt;p&gt;M（machine）才是真正对应操作系统线程的东西，负责执行代码；&lt;/p&gt;
&lt;p&gt;而中间那个 P（processor），一开始看起来最抽象，但它恰恰是整个模型成立的关键。&lt;/p&gt;
&lt;p&gt;如果把这三者放在一起看，会发现它们的分工非常克制：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;​&lt;strong&gt;G&lt;/strong&gt;：我要跑的代码&lt;/li&gt;
&lt;li&gt;​&lt;strong&gt;M&lt;/strong&gt;：真正跑代码的 OS 线程&lt;/li&gt;
&lt;li&gt;​&lt;strong&gt;P&lt;/strong&gt;：调度和执行所需的“运行环境”&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;真正让我想通的一点是：&lt;strong&gt;goroutine 不能直接被线程执行&lt;/strong&gt;，中间必须经过 P。&lt;/p&gt;
&lt;p&gt;没有 P，M 就算是空闲的，也不能随便去跑一个 G。&lt;/p&gt;
&lt;p&gt;P 可以理解成一种“执行许可”。&lt;/p&gt;
&lt;p&gt;一个 M 想执行 Go 代码，必须先拿到一个 P；而一个 P 在同一时刻，只会被一个 M 持有。于是就形成了一个非常重要的约束：&lt;strong&gt;同一时间真正并行执行 Go 代码的数量，等于 P 的数量&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;这时候再回头看 &lt;code&gt;GOMAXPROCS&lt;/code&gt;​，就会发现它不是在控制线程数，而是在控制 &lt;strong&gt;P 的数量&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;换句话说，它限制的是“允许多少个 goroutine 同时在跑”，而不是“起多少个线程”。&lt;/p&gt;
&lt;p&gt;这种多绕一层的设计，一开始确实不直观，但它解决了一个在 PHP 或传统线程模型里很难优雅处理的问题：​&lt;strong&gt;调度的可控性&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;如果只有 goroutine 和线程，那么要么 goroutine 直接绑定线程，要么线程自己去抢任务，这两种方式都会让调度策略被 OS 主导。&lt;/p&gt;
&lt;p&gt;而引入 P 之后，Go runtime 就把调度的主动权牢牢握在自己手里。goroutine 的切换、暂停、恢复，甚至迁移到另一个线程，都可以在用户态完成。&lt;/p&gt;
&lt;p&gt;从直觉上看，可以这样理解这套关系：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;G 决定“要不要跑”&lt;/li&gt;
&lt;li&gt;P 决定“现在能不能跑”&lt;/li&gt;
&lt;li&gt;M 负责“真正去跑”&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;当一个 goroutine 因为 I/O、系统调用或者 channel 操作被阻塞时，Go runtime 并不会傻等着那个线程回来，而是可以把 &lt;strong&gt;P 从这个 M 手里拿走，交给另一个空闲的 M&lt;/strong&gt;，继续跑其他 goroutine。&lt;/p&gt;
&lt;p&gt;被阻塞的那个 goroutine，等条件满足后，再重新进入调度队列。&lt;/p&gt;
&lt;p&gt;这一点对我冲击很大，因为它和 PHP 的并发直觉几乎是反着来的。&lt;/p&gt;
&lt;p&gt;在 PHP 里，一个请求一旦进入阻塞状态，这个执行单元基本就被“占住”了；&lt;/p&gt;
&lt;p&gt;而在 Go 里，阻塞的是 goroutine，而不是承载它的线程。&lt;/p&gt;
&lt;p&gt;所以，GMP 模型并不是为了炫技而复杂，而是为了让 goroutine 这种“不绑定线程的执行单元”真正可行。&lt;/p&gt;
&lt;p&gt;它通过在中间加一个 P，把“并发结构”和“系统资源”彻底解耦了。&lt;/p&gt;
&lt;p&gt;当我把这一层关系想清楚之后，很多之前看起来“有点魔法”的行为，其实就变得很朴素了：为什么 goroutine 可以大量创建，为什么阻塞 I/O 不一定拖慢整个程序，也为什么 Go 的并发更像是一种语言级能力，而不是系统能力的简单封装。&lt;/p&gt;
&lt;h3&gt;36. 为什么 Go 可以“随便起协程”&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;当前问题存在示例代码，可以前往&lt;a href=&quot;https://github.com/zxc7563598/go-lab/commit/bc4debd69a2e822ace81db93ff161ba6be671495&quot;&gt;GitHub查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;在理解了 goroutine 不等于线程、以及 GMP 是怎么把执行和调度拆开的之后，再回头看「Go 可以随便起协程」这件事，就不太容易走偏了。&lt;/p&gt;
&lt;p&gt;这里的“随便”，并不是没有代价，而是 &lt;strong&gt;代价不在你原来以为的地方&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;如果我还停留在“一个并发单元≈一个线程”的直觉里，那“起很多 goroutine”听起来几乎就是在自杀式地消耗资源。&lt;/p&gt;
&lt;p&gt;但实际上，goroutine 的成本模型和线程完全不同。&lt;/p&gt;
&lt;p&gt;它既不直接占用一个 OS 线程，也不一开始就分配一大块固定栈空间，它更像是 Go runtime 里的一条“待执行任务记录”。&lt;/p&gt;
&lt;p&gt;直观一点看，一个 goroutine 至少包含的东西其实非常有限：执行函数、当前的栈信息、以及一些调度相关的元数据。栈本身还是按需增长的。&lt;/p&gt;
&lt;p&gt;这意味着，&lt;strong&gt;起一个 goroutine 的成本，更接近一次函数调用的延伸，而不是一次线程创建&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;这也是为什么在 Go 里，经常能看到这样的代码，而不需要太多心理负担：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;go handleConn(conn)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果把这行代码放到 PHP 或传统线程模型里去理解，那几乎是在“每来一个连接就开一个线程”。&lt;/p&gt;
&lt;p&gt;但在 Go 里，这更像是告诉 runtime：这里有一段逻辑，可以并发地跑，至于什么时候跑、在哪跑、要不要暂时停一停，不是我现在关心的事情。&lt;/p&gt;
&lt;p&gt;真正限制 goroutine 并发规模的，并不是它们的数量，而是 &lt;strong&gt;P 的数量，以及底层资源是否会成为瓶颈&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;也就是说，你可以创建很多 goroutine，但真正同时在执行的，永远只有那么几个，剩下的只是排队等待调度。&lt;/p&gt;
&lt;p&gt;这一点和 PHP 的直觉差异很大。&lt;/p&gt;
&lt;p&gt;在 PHP 里，创建并发执行单元本身就是一件需要谨慎对待的事，因为它几乎等同于消耗真实的系统资源；&lt;/p&gt;
&lt;p&gt;而在 Go 里，创建 goroutine 更多是在描述并发结构，而不是立刻兑现资源。&lt;/p&gt;
&lt;p&gt;换个角度看，Go 鼓励“随便起协程”，其实是在鼓励你&lt;strong&gt;把并发当成程序结构的一部分来写&lt;/strong&gt;，而不是把它当成一种昂贵的优化手段。&lt;/p&gt;
&lt;p&gt;你先把逻辑拆清楚，哪些事情可以并行，哪些地方需要等待，runtime 再根据实际情况去做调度上的取舍。&lt;/p&gt;
&lt;p&gt;当然，这并不意味着 goroutine 是“免费的”。&lt;/p&gt;
&lt;p&gt;当 goroutine 数量膨胀到一定规模时，内存占用、调度开销、以及共享资源上的竞争，都会开始显现出来。&lt;/p&gt;
&lt;p&gt;只是这些成本不再以“线程数暴涨”的方式出现，而是更隐蔽、更延后。&lt;/p&gt;
&lt;p&gt;所以我后来对“Go 可以随便起协程”这句话的理解是：&lt;strong&gt;你可以大胆地创建 goroutine，因为它们不会立刻把系统拖垮；但是否真的应该这么做，取决于你对并发边界的设计。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这句话听起来像是给了你更大的自由，但实际上，它只是把“什么时候付出代价”这件事，推迟到了更接近业务逻辑和资源瓶颈的地方。&lt;/p&gt;
&lt;h3&gt;37. 并发 ≠ 更快：什么时候并发是负担&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;当前问题存在示例代码，可以前往&lt;a href=&quot;https://github.com/zxc7563598/go-lab/commit/bc4debd69a2e822ace81db93ff161ba6be671495&quot;&gt;GitHub查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;在理解了 goroutine 很轻、调度由 runtime 接管、并且“起很多协程”本身并不会立刻出问题之后，很容易产生一个错觉：既然如此，那并发是不是总能让程序更快？&lt;/p&gt;
&lt;p&gt;答案显然是否定的。&lt;/p&gt;
&lt;p&gt;并发解决的是&lt;strong&gt;结构问题&lt;/strong&gt;，而不是性能保证；当问题本身并不适合并发时，并发反而会成为一种负担。&lt;/p&gt;
&lt;p&gt;最直观的一类情况，是 &lt;strong&gt;CPU 资源本来就不够&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;不管起多少 goroutine，真正能同时执行的数量始终受 P 的数量限制。&lt;/p&gt;
&lt;p&gt;当所有 goroutine 做的都是纯计算时，并发只是在争抢同一批 CPU 时间片，结果往往不是更快，而是多了一层调度成本。&lt;/p&gt;
&lt;p&gt;在这种情况下，并发带来的不是吞吐提升，而是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;更多的上下文切换&lt;/li&gt;
&lt;li&gt;更复杂的执行路径&lt;/li&gt;
&lt;li&gt;更难预测的性能波动&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这点和 PHP 的多进程模型其实很相似，只是表现形式不同而已。&lt;/p&gt;
&lt;p&gt;另一类更隐蔽的负担，来自 &lt;strong&gt;共享资源&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;当多个 goroutine 需要频繁访问同一份数据、同一个锁、同一个 channel 时，并发并不会放大处理能力，反而会把瓶颈放得更明显。&lt;/p&gt;
&lt;p&gt;你看到的并不是“同时在干活”，而是“同时在等待”。&lt;/p&gt;
&lt;p&gt;在代码层面，这种负担往往体现为：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;锁竞争导致的阻塞&lt;/li&gt;
&lt;li&gt;channel 堵塞导致的 goroutine 堆积&lt;/li&gt;
&lt;li&gt;goroutine 数量很多，但真正有效工作的很少&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这时候并发结构越复杂，问题反而越难定位。&lt;/p&gt;
&lt;p&gt;还有一类情况，是 &lt;strong&gt;并发规模和任务粒度不匹配&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;如果每个 goroutine 只做一点点事情，执行时间极短，那么调度、创建、回收这些隐性的成本，就会开始占据主要比例。&lt;/p&gt;
&lt;p&gt;并发带来的收益还没出现，开销已经先付出去了。&lt;/p&gt;
&lt;p&gt;这也是我后来慢慢意识到的一点：goroutine 很轻，并不意味着“可以忽略它的成本”，而只是意味着 &lt;strong&gt;成本更分散、更不直观&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;从 PHP 转到 Go 之后，一个很容易出现的误区是，把“可以随便起协程”理解成“应该尽量并发”。&lt;/p&gt;
&lt;p&gt;但实际上，Go 的并发模型给的是表达能力，而不是性能承诺。&lt;/p&gt;
&lt;p&gt;你可以把并发写得很自然，但是否真的要并发，依然是一个需要判断的问题。&lt;/p&gt;
&lt;p&gt;到这里，这一章对我来说才算完整地闭环了：Go 之所以强调并发，不是因为它能自动让程序变快，而是因为它把并发变成了一种更容易表达、也更容易被 runtime 调度的程序结构。&lt;/p&gt;
&lt;p&gt;至于性能提升与否，取决的从来都不是 goroutine 的数量，而是问题本身是否适合并发。&lt;/p&gt;
&lt;p&gt;这也是我现在回头再看这套模型时，一个比较冷静的结论：&lt;strong&gt;并发不是捷径，它只是把复杂度换了一个位置。&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;九、channel：通信，而不是共享内存&lt;/h2&gt;
&lt;h3&gt;38. channel 的设计哲学&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;当前问题存在示例代码，可以前往&lt;a href=&quot;https://github.com/zxc7563598/go-lab/commit/b5e4aa8c0dadda049ac0f6ba11b53f9a50489b17&quot;&gt;GitHub查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;第一次看到 channel 时，很容易把它当成一种语法结构：&lt;strong&gt;一个可以在 goroutine 之间传递数据的管道&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;你往里 &lt;code&gt;send&lt;/code&gt;​ 一个值，另一端 &lt;code&gt;receive&lt;/code&gt; 到这个值，如果两边有一方没准备好，操作就会被阻塞。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;ch := make(chan int)

go func() {
    ch &amp;#x3C;- 1
}()

v := &amp;#x3C;-ch
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果只停在这里，channel 看起来更像是一个“带阻塞能力的队列”。&lt;/p&gt;
&lt;p&gt;但当我真正开始用它来组织并发逻辑时，会慢慢意识到：&lt;strong&gt;Go 并不是想给你一个更好用的共享容器，而是在引导你换一种并发思路&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;Go 在并发模型里有一句经常被引用的话：&lt;strong&gt;不要通过共享内存来通信，而要通过通信来共享内存&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;channel 正是这句话最直接、也最具体的体现。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;从设计哲学上看，channel 有几个很明显的取向。&lt;/p&gt;
&lt;p&gt;首先，它&lt;strong&gt;刻意弱化了“共享状态”这件事&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;你很少会把 channel 当成一个“大家随便用的公共变量”，更常见的反而是这种结构化的用法：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;谁创建 channel，谁决定它的生命周期&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;有些 goroutine 只负责写，有些只负责读&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func producer(ch chan&amp;#x3C;- int) {
    ch &amp;#x3C;- 1
}

func consumer(ch &amp;#x3C;-chan int) {
    fmt.Println(&amp;#x3C;-ch)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里甚至通过类型系统，把“你能不能写”这件事直接限制住了。&lt;/p&gt;
&lt;p&gt;在 PHP 里，我们更多是&lt;strong&gt;靠约定来避免误用&lt;/strong&gt;；而在 Go 里，channel 更像是在说：&lt;strong&gt;这件事我帮你从语法层面就堵死了&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;其次，channel 把“同步”变成了“通信的自然结果”。&lt;/p&gt;
&lt;p&gt;在很多并发模型中，同步是显式存在的概念：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;加锁 / 解锁&lt;/li&gt;
&lt;li&gt;等待 / 通知&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;而在 channel 的语义里，你往往不会单独去写“同步逻辑”：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;ch &amp;#x3C;- data
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这一行代码既表达了“我要传一个值”，也隐含了：&lt;strong&gt;在有人接收之前，这一步不会继续往下走&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;同步不再是额外的控制结构，而是通信本身的一部分，这会直接改变你组织代码时的关注点。&lt;/p&gt;
&lt;p&gt;第三，channel 明显在鼓励你&lt;strong&gt;先想清楚数据是如何流动的&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;当我用锁来写并发代码时，脑子里更多想的是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;哪些变量是共享的&lt;/li&gt;
&lt;li&gt;哪些地方需要加锁&lt;/li&gt;
&lt;li&gt;锁会不会忘记释放&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;而当我用 channel 时，思路会自然变成：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;数据从哪里产生&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;经过哪些 goroutine&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;最终由谁来消费&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;jobs := make(chan Job)
results := make(chan Result)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;光看这些名字，就已经在描述系统的结构，而不是底层的并发细节。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;所以后来我对 channel 的理解发生了一个明显的转变：&lt;strong&gt;它不是用来“让我不犯错”的工具，而是用来逼我把并发关系想清楚的工具&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;当你觉得 channel 用起来很别扭时，很多时候并不是语法的问题，而是并发结构本身还没有想清楚。&lt;/p&gt;
&lt;p&gt;这一点，对习惯了 PHP 那种“共享状态是默认存在的世界”的人来说，感受会尤其明显。&lt;/p&gt;
&lt;h3&gt;39. 无缓冲 channel vs 有缓冲 channel&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;当前问题存在示例代码，可以前往&lt;a href=&quot;https://github.com/zxc7563598/go-lab/commit/b5e4aa8c0dadda049ac0f6ba11b53f9a50489b17&quot;&gt;GitHub查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;刚看到无缓冲 channel 和有缓冲 channel 的时候，很容易把区别理解成一句话：&lt;strong&gt;一个容量是 0，一个容量大于 0&lt;/strong&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;ch1 := make(chan int)    // 无缓冲
ch2 := make(chan int, 3) // 有缓冲
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但如果只停在“能不能存东西”这个层面，其实很难理解 Go 为什么要把这两种形式都作为一等公民提供出来。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;无缓冲 channel&lt;/strong&gt; 带给我的第一个强烈感受是：它不是在“存数据”，而是在&lt;strong&gt;强制一次同步交接&lt;/strong&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;ch := make(chan int)

go func() {
    ch &amp;#x3C;- 1
    // 这里会等待
    fmt.Println(&quot;send done&quot;)
}()

time.Sleep(time.Millisecond * 100)
fmt.Println(&amp;#x3C;-ch) // 此处输出完成后，才会输出 send done
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在这里，&lt;code&gt;ch &amp;#x3C;- 1&lt;/code&gt; 并不会因为“值已经算好了”就立刻结束。&lt;/p&gt;
&lt;p&gt;它表达的是：&lt;strong&gt;我现在有一个值，只有当你准备好接收时，这一步才算完成&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;后来我慢慢意识到，无缓冲 channel 更像是在建模一种关系： &lt;strong&gt;“你我必须在同一个时间点达成一致”&lt;/strong&gt; 。&lt;/p&gt;
&lt;p&gt;从这个角度看，它的语义其实非常强：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;发送方知道：一定有人正在接&lt;/li&gt;
&lt;li&gt;接收方知道：一定有人正在发&lt;/li&gt;
&lt;li&gt;同步点被明确地放在了“数据交接”这个动作上&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;它并不关心吞吐量，也不关心性能优化，它关心的是​&lt;strong&gt;并发结构是否清晰&lt;/strong&gt;。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;有缓冲 channel&lt;/strong&gt; 则明显在表达另一种取向：&lt;strong&gt;发送和接收可以在一定程度上解耦&lt;/strong&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;ch := make(chan int, 2)

ch &amp;#x3C;- 1
ch &amp;#x3C;- 2
// 只有 ch 超过 2 位才会堵塞，因此会先输出 send done
fmt.Println(&quot;send done&quot;)
fmt.Println(&amp;#x3C;-ch)
fmt.Println(&amp;#x3C;-ch)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;只要缓冲区还有空间，发送方就可以继续往下走，而不必等某个 goroutine 立刻来接。&lt;/p&gt;
&lt;p&gt;这里的 channel 更像是在说：我不要求你“此刻”就处理这个值，但我保证这些值会按顺序交给你。&lt;/p&gt;
&lt;p&gt;所以有缓冲 channel 引入的，其实不是“性能优化”这么简单，而是​&lt;strong&gt;时间上的松弛度&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;发送方可以跑得稍微快一点&lt;/li&gt;
&lt;li&gt;接收方可以慢一点再处理&lt;/li&gt;
&lt;li&gt;两者之间通过缓冲区形成了一个“过渡层”&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;p&gt;但有意思的是，这两种 channel 的设计，并没有谁“更高级”。&lt;/p&gt;
&lt;p&gt;它们关注的重点完全不同。&lt;/p&gt;
&lt;p&gt;无缓冲 channel 更偏向于：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;表达明确的同步关系&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;作为 goroutine 之间的“会合点”&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;让并发结构本身变得可读&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;而有缓冲 channel 更偏向于：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;平衡生产和消费速度&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;削弱时间上的强耦合&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;提高系统的整体吞吐能力&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;我后来反而发现，一个挺实用的判断方式是：&lt;strong&gt;如果我一开始就知道“这里需要多大缓冲”，那往往说明我已经在考虑系统节奏了&lt;/strong&gt;；&lt;/p&gt;
&lt;p&gt;而如果我更关心“这两步必须严格对齐”，无缓冲 channel 通常更贴合我的直觉。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;从 PHP 的视角看，这个区别也挺有意思。&lt;/p&gt;
&lt;p&gt;无缓冲 channel 更像是一次“同步调用的拆分版本”；&lt;/p&gt;
&lt;p&gt;而有缓冲 channel，则更接近我们熟悉的消息队列，但被压缩进了语言层面。&lt;/p&gt;
&lt;p&gt;所以它们的差异，并不只是“能不能存几个值”，而是在表达：&lt;strong&gt;你到底想要的是一次强同步的交接，还是一次允许错峰的传递&lt;/strong&gt;。&lt;/p&gt;
&lt;h3&gt;40. 谁负责关闭 channel&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;当前问题存在示例代码，可以前往&lt;a href=&quot;https://github.com/zxc7563598/go-lab/commit/b5e4aa8c0dadda049ac0f6ba11b53f9a50489b17&quot;&gt;GitHub查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;先从一个容易踩到的直觉说起。&lt;/p&gt;
&lt;p&gt;很多人（包括我一开始）会把 &lt;code&gt;close(channel)&lt;/code&gt;​ 理解成：&lt;strong&gt;我用完了，顺手关一下&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;但 channel 并不是文件，也不是数据库连接。&lt;/p&gt;
&lt;p&gt;它本身不占用什么稀缺资源，&lt;strong&gt;不关闭并不会导致泄漏&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;真正会出问题的，是&lt;strong&gt;对已经关闭的 channel 继续发送数据&lt;/strong&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;close(ch)
ch &amp;#x3C;- 1 // panic
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所以“要不要关”，从一开始就不是一个资源管理问题。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;后来我慢慢意识到一个更重要的事实：&lt;strong&gt;关闭 channel，本质上是一种广播行为&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;当一个 channel 被关闭时：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;接收方仍然可以继续读&lt;/li&gt;
&lt;li&gt;读到的将是零值，并且可以通过第二个返回值判断结束&lt;/li&gt;
&lt;li&gt;所有正在阻塞等待接收的 goroutine，都会被同时唤醒&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;v, ok := &amp;#x3C;-ch
if !ok {
    // channel 已关闭，且不会再有新数据
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这意味着，&lt;code&gt;close&lt;/code&gt;​ 表达的并不是“我不想用了”，而是：&lt;strong&gt;我明确告诉所有接收方：不会再有新数据了&lt;/strong&gt;。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;从这个角度再回来看“谁负责关闭 channel”，答案就变得清晰了：&lt;strong&gt;谁负责发送最后一个值，谁负责关闭 channel&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;更具体一点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;只有发送方才应该关闭 channel&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;接收方永远不应该关闭它&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;多发送方场景下，通常不由具体某一个 goroutine 来关&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;原因其实很朴素：&lt;/p&gt;
&lt;p&gt;接收方并不知道“还有没有人会继续发送”，它没有这个全局视角。&lt;/p&gt;
&lt;p&gt;如果接收方擅自关闭，就等于替别人做了生命周期决策。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;// 常见且安全的模式
go func() {
    defer close(ch)
    for _, v := range data {
        ch &amp;#x3C;- v
    }
}()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的 &lt;code&gt;close&lt;/code&gt;​，不是清理，而是一个​&lt;strong&gt;明确的完成信号&lt;/strong&gt;。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;在多发送者的场景下，这个问题会变得更直观。&lt;/p&gt;
&lt;p&gt;如果多个 goroutine 同时往同一个 channel 发数据：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;go worker1(ch)
go worker2(ch)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这时几乎可以肯定：&lt;strong&gt;任何一个 worker 都不适合去关闭这个 channel&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;通常的做法反而是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;由一个“协调者”持有关闭权&lt;/li&gt;
&lt;li&gt;或者通过 &lt;code&gt;sync.WaitGroup&lt;/code&gt; 等方式，等所有发送者结束后统一关闭&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;go func() {
    wg.Wait()
    close(ch)
}()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里关闭 channel 的 goroutine，本质上是站在“发送方整体”的立场。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;从写代码的体验上看，我后来会把 &lt;code&gt;close(channel)&lt;/code&gt;​ 当成一句语义非常重的话：它不是一个“善后动作”，而是&lt;strong&gt;并发协议的一部分&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;什么时候关闭、由谁关闭、关闭意味着什么，这些问题如果想不清楚，代码往往就会开始变得脆弱。&lt;/p&gt;
&lt;p&gt;所以这个问题最终并不是“谁来写 &lt;code&gt;close&lt;/code&gt;​”，而是：&lt;strong&gt;你有没有想清楚，这条 channel 在你的并发结构里，什么时候才算真正结束&lt;/strong&gt;。&lt;/p&gt;
&lt;h3&gt;41. 使用 channel 避免共享状态&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;当前问题存在示例代码，可以前往&lt;a href=&quot;https://github.com/zxc7563598/go-lab/commit/b5e4aa8c0dadda049ac0f6ba11b53f9a50489b17&quot;&gt;GitHub查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;在我刚接触 Go 并发时，“使用 channel 避免共享状态”这句话其实有点抽象。&lt;/p&gt;
&lt;p&gt;因为在 PHP 的经验里，&lt;strong&gt;共享状态几乎是默认存在的&lt;/strong&gt;：数据库、缓存、全局变量、单例服务……它们并不是被避免的对象，而是被管理、被约定、被约束的对象。&lt;/p&gt;
&lt;p&gt;所以一开始，我很自然地会把并发问题理解成：&lt;strong&gt;既然状态是共享的，那我该如何保证它是安全的&lt;/strong&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;var mu sync.Mutex
var wg sync.WaitGroup
count := 0

wg.Add(2)

go func() {
	defer wg.Done()

	mu.Lock()
	count++
	mu.Unlock()
}()

go func() {
	defer wg.Done()

	mu.Lock()
	count++
	mu.Unlock()
}()

wg.Wait()
fmt.Println(count)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这种写法并没有错，但它隐含了一个前提：&lt;strong&gt;所有 goroutine 都必须知道这份状态的存在，并且遵守同一套访问规则&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;一旦有一个地方破坏了规则，问题就会变得很难追踪。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;而当我开始用 channel 重写类似逻辑时，思路发生了一个比较明显的变化。&lt;/p&gt;
&lt;p&gt;关注点不再是“怎么保护这份数据”，而是变成了：&lt;strong&gt;这份状态到底应该归谁所有&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;比如下面这个例子里，我让多个 goroutine 只负责“产生数据”，&lt;/p&gt;
&lt;p&gt;而把“累加结果”这件事，交给一个专门的接收者来完成：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;ch := make(chan int)
var wg sync.WaitGroup

// 启动多个发送者
for i := 1; i &amp;#x3C; 10; i++ {
	wg.Add(1)
	go func(val int) {
		defer wg.Done()
		ch &amp;#x3C;- val
	}(i)
}

// 启动接收者，独占状态
resultChan := make(chan int)
go func() {
	total := 0
	for v := range ch {
		total += v
	}
	resultChan &amp;#x3C;- total
	close(resultChan)
}()

// 等待所有发送完成后再关闭 channel
go func() {
	wg.Wait()
	close(ch)
}()

result := &amp;#x3C;-resultChan
fmt.Println(&quot;总和:&quot;, result) // 输出: 总和: 45
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在这段代码里，真正“持有状态”的，只有接收者那个 goroutine。&lt;/p&gt;
&lt;p&gt;​&lt;code&gt;total&lt;/code&gt;​ 从头到尾只存在于它自己的执行上下文中，其他 goroutine &lt;strong&gt;既不知道它的存在，也没有任何方式去修改它&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;这时候，“避免共享状态”这句话才开始变得具体起来。&lt;/p&gt;
&lt;p&gt;不是说状态消失了，而是：&lt;strong&gt;状态被明确地收敛到了一个地方&lt;/strong&gt;。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;另外一个让我印象很深的点，是 channel 在这里自然地承担了“边界”的角色。&lt;/p&gt;
&lt;p&gt;发送者只负责一件事：&lt;strong&gt;我把值送出去，至于你怎么用，我不关心&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;接收者只负责另一件事：&lt;strong&gt;我顺序地接收这些值，并维护我自己的状态&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;至于 channel 什么时候结束，也不是由接收者来猜的，而是由发送方整体明确地给出信号：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;wg.Wait()
close(ch)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这一行代码，本质上是在说：&lt;strong&gt;不会再有新的数据了，你可以放心收尾了。&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;从这个角度回头看，channel 并不是在“帮我解决共享状态的问题”，而是在&lt;strong&gt;改变我设计并发代码的默认路径&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;与其先假设状态是共享的，再想办法把它保护起来，不如一开始就问清楚：&lt;strong&gt;这份状态到底有没有必要被共享&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;而 channel，恰好给了我一种把这个问题落到代码结构里的方式。&lt;/p&gt;
&lt;h3&gt;42. channel 常见死锁场景分析&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;当前问题存在示例代码，可以前往&lt;a href=&quot;https://github.com/zxc7563598/go-lab/commit/b5e4aa8c0dadda049ac0f6ba11b53f9a50489b17&quot;&gt;GitHub查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;channel 的死锁并不是偶发事故，而是&lt;strong&gt;非常稳定地出现在几种固定结构里&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;而且这些死锁，往往不是因为代码写错了，而是因为&lt;strong&gt;并发关系没有被完整表达出来&lt;/strong&gt;。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;最常见的一类，是​&lt;strong&gt;没有接收者的发送&lt;/strong&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;ch := make(chan int)
ch &amp;#x3C;- 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这段代码本身没有任何语法错误，但如果它运行在当前 goroutine 里，就会直接卡住。&lt;/p&gt;
&lt;p&gt;原因也很直白：&lt;strong&gt;无缓冲 channel 的发送，必须等到有人接收才能完成&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;这种死锁的本质，不是“忘了开 goroutine”，而是：&lt;strong&gt;你写下了一次通信，却没有给它安排对应的另一端&lt;/strong&gt;。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;另一类很容易出现的，是​&lt;strong&gt;接收者在等一个永远不会结束的 channel&lt;/strong&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;ch := make(chan int)

go func() {
    ch &amp;#x3C;- 1
}()

for v := range ch {
    fmt.Println(v)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的 &lt;code&gt;range ch&lt;/code&gt;​ 看起来很自然，但它隐含了一个前提：&lt;strong&gt;这个 channel 迟早会被关闭&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;如果没有任何地方 &lt;code&gt;close(ch)&lt;/code&gt;，接收者就会一直等下去。&lt;/p&gt;
&lt;p&gt;这个问题在代码量变大之后尤其隐蔽，因为你很难第一眼看出“到底谁该负责关闭”。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;第三类，是​&lt;strong&gt;缓冲 channel 被写满，却没有人再继续接收&lt;/strong&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;ch := make(chan int, 2)

ch &amp;#x3C;- 1
ch &amp;#x3C;- 2
ch &amp;#x3C;- 3 // 阻塞
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里不是因为 channel 有缓冲就“更安全”，而是：&lt;strong&gt;缓冲只是延后了同步发生的时间，并没有消除同步本身&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;当缓冲区写满之后，发送方仍然必须等待接收方出现。&lt;/p&gt;
&lt;p&gt;如果接收方的生命周期已经结束，或者根本不存在，死锁依然会发生。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;还有一类，在结构上更“高级”，也更常出现在真实代码里：&lt;strong&gt;goroutine 之间互相等待，但没有任何一方能够先继续往下走&lt;/strong&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;ch1 := make(chan int)
ch2 := make(chan int)

go func() {
    ch1 &amp;#x3C;- 1
    &amp;#x3C;-ch2
}()

go func() {
    ch2 &amp;#x3C;- 1
    &amp;#x3C;-ch1
}()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的死锁，并不是因为 channel 用错了，而是因为：&lt;strong&gt;通信顺序本身是矛盾的&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;每个 goroutine 都在等待对方先完成某一步，但这一步永远不会发生。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;把这些场景放在一起看，我后来意识到一件事：channel 的死锁，几乎都不是“技术细节问题”，而是&lt;strong&gt;协议问题&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;每一个 channel，都隐含了一套并发协议：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;谁负责发送&lt;/li&gt;
&lt;li&gt;谁负责接收&lt;/li&gt;
&lt;li&gt;什么时候开始&lt;/li&gt;
&lt;li&gt;什么时候结束&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;只要其中任何一条没有被明确表达出来，死锁就很容易出现。&lt;/p&gt;
&lt;p&gt;所以我现在反而会把“遇到 channel 死锁”当成一个信号：&lt;strong&gt;不是去急着修这一行代码，而是回头检查，我是不是有一段通信关系没有被完整地想清楚&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;从这个角度看，channel 的死锁并不是 Go 的坑，而是 Go 把并发设计中的问题，&lt;strong&gt;非常诚实地暴露了出来&lt;/strong&gt;。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;十、context：协程的生命周期管理&lt;/h2&gt;
&lt;h3&gt;43. context 的设计初衷&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;当前问题存在示例代码，可以前往&lt;a href=&quot;https://github.com/zxc7563598/go-lab/commit/b4d1546028291bc6c05113c9164cf6c14560a6f3&quot;&gt;GitHub查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;在一开始看 &lt;code&gt;context&lt;/code&gt; 的时候，我其实是有点困惑的。&lt;/p&gt;
&lt;p&gt;在 PHP 的世界里，请求本身就像一个天然的「生命周期边界」：&lt;/p&gt;
&lt;p&gt;请求进来，代码从上往下执行；请求结束，进程要么退出、要么回到空闲池子里等待下一个请求。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;不需要显式告诉谁“该结束了”&lt;/strong&gt; ，一切都会自然结束。&lt;/p&gt;
&lt;p&gt;但在 Go 里，这个前提并不存在。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;context 的设计初衷，并不是为了“传参数”，而是为了“传递一件事”：你应该什么时候停下来&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Go 选择了 goroutine 这种极轻量的并发模型之后，就顺带引入了一个问题：goroutine 一旦启动，并不会因为“调用它的函数返回了”而自动结束。&lt;/p&gt;
&lt;p&gt;换句话说，在 Go 里：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;函数返回 ≠ 工作结束&lt;/li&gt;
&lt;li&gt;请求结束 ≠ 所有相关逻辑结束&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果不额外做点什么，goroutine 是可以​&lt;strong&gt;脱离请求、脱离调用栈，继续活着的&lt;/strong&gt;。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;从这个角度再看 &lt;code&gt;context&lt;/code&gt;​，它更像是一种​&lt;strong&gt;跨调用栈的“取消信号”机制&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;它解决的并不是“我怎么把数据往下传”，而是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;上游什么时候决定放弃这次操作&lt;/li&gt;
&lt;li&gt;下游如何感知“这件事已经没有继续做下去的意义了”&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;而且这个“放弃”，往往并不是错误，只是​&lt;strong&gt;时机到了&lt;/strong&gt;。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;在 PHP 里，这个判断通常是隐含的：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;客户端断开了&lt;/li&gt;
&lt;li&gt;请求超时了&lt;/li&gt;
&lt;li&gt;框架决定结束响应&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;你几乎不用思考：&lt;strong&gt;如果这个请求已经结束了，那我刚刚启动的逻辑会怎样？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;因为答案通常是：​&lt;strong&gt;它已经不复存在了&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;但在 Go 里，如果你在处理一个 Web 请求时：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;启动了一个 goroutine 去查数据库&lt;/li&gt;
&lt;li&gt;又启动了一个 goroutine 去调第三方接口&lt;/li&gt;
&lt;li&gt;甚至再包了一层异步重试逻辑&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;那么当客户端断开连接的那一刻：&lt;strong&gt;谁来告诉这些 goroutine：你们可以停了？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;​&lt;code&gt;context&lt;/code&gt; 正是为了解决这个“通知链条”问题而出现的。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;我后来慢慢意识到，&lt;code&gt;context&lt;/code&gt;​ 本质上并不是一个“控制工具”，而是一种​&lt;strong&gt;协作约定&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;它并不会强制终止 goroutine，也不会替你回收资源。&lt;/p&gt;
&lt;p&gt;它只是提供了一种&lt;strong&gt;统一、可传播的方式&lt;/strong&gt;，去表达一件非常简单的状态：&lt;strong&gt;这件事情，还值得继续做吗？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;至于要不要停、怎么停，完全取决于 goroutine 自己是否愿意去尊重这个信号。&lt;/p&gt;
&lt;h3&gt;44. context.WithCancel / Timeout / Deadline&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;当前问题存在示例代码，可以前往&lt;a href=&quot;https://github.com/zxc7563598/go-lab/commit/b4d1546028291bc6c05113c9164cf6c14560a6f3&quot;&gt;GitHub查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;在理解了 &lt;code&gt;context&lt;/code&gt;​ 是“用来传递&lt;strong&gt;是否还值得继续&lt;/strong&gt;”这个信号之后，再回头看&lt;/p&gt;
&lt;p&gt;​&lt;code&gt;WithCancel&lt;/code&gt;​、&lt;code&gt;WithTimeout&lt;/code&gt;​、&lt;code&gt;WithDeadline&lt;/code&gt;，我反而没那么纠结它们的 API 差异了。&lt;/p&gt;
&lt;p&gt;它们本质上都在做同一件事：&lt;strong&gt;创建一个“有明确结束条件”的 context&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;区别只在于：&lt;strong&gt;这个“结束”的决定，是由谁、在什么时候做出的。&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;​&lt;code&gt;context.WithCancel&lt;/code&gt; 给了我一种最“显式”的感觉。&lt;/p&gt;
&lt;p&gt;它并不关心时间，也不关心外部世界发生了什么，&lt;br&gt;
它只是告诉你一件事：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;我现在创建了一个 context&lt;/li&gt;
&lt;li&gt;但什么时候结束，由我来决定&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这种模式在 Go 里非常常见，尤其是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;上游逻辑已经确定不再需要结果&lt;/li&gt;
&lt;li&gt;或者某个条件已经满足，后续工作变得多余&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;在 PHP 的语境里，这种“我说停就停”的能力其实很少显式存在，因为请求结束本身就已经是一次“全局 cancel”。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;// 创建一个可取消的上下文，ctx为上下文，cancel为取消函数
ctx, cancel := context.WithCancel(context.Background())

go func() {
	for {
		select {
		case &amp;#x3C;-ctx.Done():
			fmt.Println(&quot;收到取消信号，退出 goroutine&quot;)
			return
		default:
			fmt.Println(&quot;goroutine 正在工作&quot;)
			time.Sleep(500 * time.Millisecond)
		}
	}
}()

time.Sleep(2 * time.Second)
cancel() // 主动告诉它：不用再干了
time.Sleep(1 * time.Second)
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;p&gt;​&lt;code&gt;context.WithTimeout&lt;/code&gt;​ 和 &lt;code&gt;context.WithDeadline&lt;/code&gt;，则更像是把“放弃的决定”交给时间。&lt;/p&gt;
&lt;p&gt;这点一开始我有点不太适应。&lt;/p&gt;
&lt;p&gt;在 PHP 中，超时往往是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;nginx 超时&lt;/li&gt;
&lt;li&gt;php-fpm 超时&lt;/li&gt;
&lt;li&gt;数据库超时&lt;/li&gt;
&lt;li&gt;或者框架层统一兜底&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这些超时是&lt;strong&gt;外部环境强加的限制&lt;/strong&gt;，你写代码时，往往只是被动接受。&lt;/p&gt;
&lt;p&gt;但 Go 把这个选择权下放到了代码层面。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;// 创建一个带超时自动取消的上下文，2秒后超时自动取消，ctx为上下文，cancel为取消函数可以提前手动调用
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

go func() {
	for {
		select {
		case &amp;#x3C;-ctx.Done():
			fmt.Println(&quot;超时了，不等了&quot;)
			return
		default:
			fmt.Println(&quot;还在尝试完成任务&quot;)
			time.Sleep(500 * time.Millisecond)
		}
	}
}()

time.Sleep(3 * time.Second)
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;// 创建一个在指定绝对时间点自动取消的上下文，ctx为上下文，cancel为取消函数可以提前手动调用
deadline := time.Now().Add(2 * time.Second)
ctx, cancel := context.WithDeadline(context.Background(), deadline)
defer cancel()

go func() {
	for {
		select {
		case &amp;#x3C;-ctx.Done():
			fmt.Println(&quot;超过截止时间，结束&quot;)
			return
		default:
			fmt.Println(&quot;在截止时间前尝试完成&quot;)
			time.Sleep(500 * time.Millisecond)
		}
	}
}()

time.Sleep(3 * time.Second)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;​&lt;code&gt;WithTimeout&lt;/code&gt; 给的是一种相对时间的承诺：从现在开始，如果这件事在 N 时间内还没完成，那就算了。&lt;/p&gt;
&lt;p&gt;而 &lt;code&gt;WithDeadline&lt;/code&gt; 更像是在说：到某一个确定的时间点之前，如果还没结束，就不值得继续了。&lt;/p&gt;
&lt;p&gt;这两者在效果上几乎一致，但表达的意图略有不同：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;一个是“我最多愿意等这么久”&lt;/li&gt;
&lt;li&gt;一个是“我只能等到这个时间点”&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;在复杂调用链里，这种区别有时并不是给机器看的，而是给&lt;strong&gt;未来的自己或协作者看的&lt;/strong&gt;。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;慢慢理解这些之后，我开始意识到一个以前没太注意的点：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;这些 context 并不是为了“让 goroutine 更聪明”，而是为了让“放弃变得可传播”。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;一旦上游决定放弃：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;子 context 会被一并取消&lt;/li&gt;
&lt;li&gt;下游所有愿意监听这个信号的 goroutine，都能同步感知&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这是一种非常“反脚本语言”的思路。&lt;/p&gt;
&lt;p&gt;在脚本语言里，代码天然是线性的、短命的，“放弃”通常意味着异常、return、或者进程结束。&lt;/p&gt;
&lt;p&gt;而在 Go 里，“放弃”被拆解成了一种状态，它可以被检查、被传递、被尊重，也可以被忽略。&lt;/p&gt;
&lt;h3&gt;45. 为什么 context 不应该传业务参数&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;当前问题存在示例代码，可以前往&lt;a href=&quot;https://github.com/zxc7563598/go-lab/commit/b4d1546028291bc6c05113c9164cf6c14560a6f3&quot;&gt;GitHub查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;一开始看到「​&lt;strong&gt;context 不应该传业务参数&lt;/strong&gt;」这条约定时，我其实是有点不服气的。&lt;/p&gt;
&lt;p&gt;因为站在一个长期写 PHP 的人的角度，这件事看起来&lt;strong&gt;既顺手又合理&lt;/strong&gt;：反正函数参数要往下传，那多塞一个进去有什么问题？&lt;/p&gt;
&lt;p&gt;而且 context 本来就是“贯穿整个调用链”的那个东西，不正好吗？&lt;/p&gt;
&lt;p&gt;但后来我慢慢意识到，这个约定并不是语法洁癖，而是在&lt;strong&gt;刻意保护 context 的语义边界&lt;/strong&gt;。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;​&lt;code&gt;context&lt;/code&gt;​ 被设计出来，是为了解决一件非常单一的事情：&lt;strong&gt;这条执行链，现在还有效吗？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;它关心的是​&lt;strong&gt;生命周期&lt;/strong&gt;​，而不是​&lt;strong&gt;业务含义&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;一旦你开始往 context 里塞业务参数，这两件事就被强行绑在了一起。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;最直观的问题是：​&lt;strong&gt;context 会被“越传越远”&lt;/strong&gt; 。&lt;/p&gt;
&lt;p&gt;在 Go 的习惯用法里，context 往往会一路传到：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;数据库访问层&lt;/li&gt;
&lt;li&gt;RPC / HTTP 客户端&lt;/li&gt;
&lt;li&gt;缓存、队列、第三方 SDK&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这些地方之所以接收 context，并不是因为它们关心你的业务，而是因为它们愿意尊重“取消 / 超时”这个信号。&lt;/p&gt;
&lt;p&gt;如果这时候 context 里还混着：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;userId&lt;/li&gt;
&lt;li&gt;orderId&lt;/li&gt;
&lt;li&gt;一些业务配置&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;那么问题就来了：这些底层组件 &lt;strong&gt;理论上不应该知道&lt;/strong&gt;，却 &lt;strong&gt;技术上完全可以拿到&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;context 在这里就从一个“公共信号”，变成了一个&lt;strong&gt;隐形的全局变量&lt;/strong&gt;。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;第二个让我开始警惕的点是：&lt;strong&gt;业务参数一旦进了 context，就很难再被替换或约束。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;在 PHP 里，如果我想改一个函数的参数结构，影响范围通常是清晰的、可见的。&lt;/p&gt;
&lt;p&gt;但 context 是“无类型约束”的：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;ctx = context.WithValue(ctx, &quot;user&quot;, user)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这种写法短期看很省事，但从阅读代码的角度来说，它几乎是&lt;strong&gt;不可发现的依赖&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;你在看一个函数签名时：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func DoSomething(ctx context.Context) error
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;你完全无法知道：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;它是否依赖某个业务值&lt;/li&gt;
&lt;li&gt;依赖的是哪个 key&lt;/li&gt;
&lt;li&gt;在什么情况下这个值是必须存在的&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;依赖被藏起来了，而隐藏依赖，几乎永远都是坏消息。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;还有一个点，是我后来才意识到的：&lt;strong&gt;context 的取消是“传染性”的，但业务数据不该是。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;当你从一个 context 派生出子 context 时：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;取消信号会自动向下传播&lt;/li&gt;
&lt;li&gt;超时和 deadline 也会一起继承&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;但如果你把业务参数塞进去，它们也会被&lt;strong&gt;不加区分地继承&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;这意味着：一个原本只属于“请求级别”的业务数据，可能会被带进一些生命周期更短、语义完全不同的子任务中。&lt;/p&gt;
&lt;p&gt;这在逻辑上是很混乱的。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;后来我开始把 context 当成一种​&lt;strong&gt;非常克制的基础设施&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;它只回答三个问题：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;这件事还能不能继续？&lt;/li&gt;
&lt;li&gt;有没有超时或截止时间？&lt;/li&gt;
&lt;li&gt;如果不能继续了，为什么（&lt;code&gt;ctx.Err()&lt;/code&gt;）&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;除此之外，它刻意什么都不管。&lt;/p&gt;
&lt;p&gt;如果你把业务参数塞进去，其实是在把一个“生命周期问题”，悄悄升级成了一个“数据承载问题”。&lt;/p&gt;
&lt;p&gt;所以我现在更愿意接受那条看起来有点教条的建议：&lt;strong&gt;context 只用来传控制信号，不用来传业务含义。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;46. Web 请求结束后 goroutine 应该如何退出&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;当前问题存在示例代码，可以前往&lt;a href=&quot;https://github.com/zxc7563598/go-lab/commit/b4d1546028291bc6c05113c9164cf6c14560a6f3&quot;&gt;GitHub查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;在 PHP 中，一个 Web 请求的结束，本身就是一次​&lt;strong&gt;强制的生命周期收束&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;请求返回&lt;/li&gt;
&lt;li&gt;脚本执行结束&lt;/li&gt;
&lt;li&gt;变量被销毁&lt;/li&gt;
&lt;li&gt;资源被回收&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;哪怕你在请求里写了看起来很“异步”的代码，本质上也只是同步执行的另一种表达方式：&lt;strong&gt;请求一停，世界就停了。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;但在 Go 里，请求结束这件事，本身​&lt;strong&gt;不会自动影响 goroutine&lt;/strong&gt;。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;我一开始的直觉是这样的：Web 请求结束了，那我刚刚启动的 goroutine 应该也就“没用了”吧？&lt;/p&gt;
&lt;p&gt;但 Go 并不会替你做这个判断。&lt;/p&gt;
&lt;p&gt;你在 handler 里 &lt;code&gt;go func() { ... }()&lt;/code&gt; 启动的 goroutine：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;不属于这个 handler&lt;/li&gt;
&lt;li&gt;不属于这个请求&lt;/li&gt;
&lt;li&gt;只属于 runtime&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果你什么都不做，它是可以​&lt;strong&gt;在请求返回之后继续跑的&lt;/strong&gt;。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;所以问题的关键，并不是：goroutine 会不会退出？&lt;/p&gt;
&lt;p&gt;而是：&lt;strong&gt;你有没有告诉它：请求已经结束了？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;在标准的 Go Web 框架中（不论是 &lt;code&gt;net/http&lt;/code&gt; 还是上层封装），&lt;/p&gt;
&lt;p&gt;请求级别的 &lt;code&gt;context&lt;/code&gt; 往往会在以下时机被取消：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;客户端断开连接&lt;/li&gt;
&lt;li&gt;请求处理完成&lt;/li&gt;
&lt;li&gt;超时或被中间件终止&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这意味着，&lt;strong&gt;请求结束本身并不会“杀死 goroutine”&lt;/strong&gt; ，它只是会让 &lt;code&gt;ctx.Done()&lt;/code&gt; 变成可读状态。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;如果把这个过程写成最简的结构，大概是这样：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func handler(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()

	go func() {
		select {
		case &amp;#x3C;-ctx.Done():
			fmt.Println(&quot;请求结束了，我该退出了&quot;)
			return
		default:
			doSomething()
		}
	}()

	w.Write([]byte(&quot;response&quot;))
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里真正决定 goroutine 是否退出的，不是请求是否结束，而是 goroutine &lt;strong&gt;有没有在合适的位置检查 context&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;如果它检查了，并选择尊重这个信号，它就能“正确退出”；&lt;/p&gt;
&lt;p&gt;如果它没检查，那它就会继续跑，直到逻辑自然结束，或者永远不结束。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;这也是我后来慢慢接受的一个事实：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;在 Go 里，goroutine 的退出是“协作式”的，而不是“附带发生的”。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Web 请求结束，只是一个信号源；&lt;/p&gt;
&lt;p&gt;goroutine 是否结束，是它自己的责任。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;在实践中，这通常意味着两件事：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;不要启动“脱离 context 的 goroutine”&lt;/li&gt;
&lt;li&gt;所有可能长期运行的逻辑，都应该能被 &lt;code&gt;ctx.Done()&lt;/code&gt; 打断&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;尤其是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;循环&lt;/li&gt;
&lt;li&gt;阻塞 IO&lt;/li&gt;
&lt;li&gt;重试逻辑&lt;/li&gt;
&lt;li&gt;等待外部资源&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果这些地方没有 context 的参与，&lt;br&gt;
那 Web 请求结束与否，对它们来说是​&lt;strong&gt;完全无感的&lt;/strong&gt;。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;所以我现在看“Web 请求结束后 goroutine 应该如何退出”这个问题，答案反而变得很朴素：&lt;strong&gt;它不会“应该”自动退出，除非你从一开始，就让它知道什么时候该退出。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;而这个“知道”的方式，几乎永远就是：&lt;strong&gt;context&lt;/strong&gt;。&lt;/p&gt;
&lt;h3&gt;47. context 泄漏的隐患&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;当前问题存在示例代码，可以前往&lt;a href=&quot;https://github.com/zxc7563598/go-lab/commit/b4d1546028291bc6c05113c9164cf6c14560a6f3&quot;&gt;GitHub查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;这里的“泄漏”，并不是 context 本身没被释放，而是​&lt;strong&gt;围绕着 context 建立的那整套协作关系，没有被正确结束&lt;/strong&gt;。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;context 最大的特点是：&lt;strong&gt;它本身几乎什么都不做，但很多东西都会围着它转。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;一旦你用 &lt;code&gt;WithCancel / WithTimeout / WithDeadline&lt;/code&gt; 创建了一个 context，你其实同时创建了几件事：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;一个可以被关闭的 &lt;code&gt;Done()&lt;/code&gt; channel&lt;/li&gt;
&lt;li&gt;可能存在的定时器&lt;/li&gt;
&lt;li&gt;一条向下传播的取消链路&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果这条链路的“源头”没有被正确收束，下游所有依赖它的 goroutine，就都有可能一直活着。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;最常见、也最容易被忽略的一种情况，是​&lt;strong&gt;忘记调用 cancel&lt;/strong&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func doSomething() {
	ctx, _ := context.WithTimeout(context.Background(), time.Minute)

	go func() {
		&amp;#x3C;-ctx.Done()
		fmt.Println(&quot;结束&quot;)
	}()
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;从逻辑上看，这段代码“好像也没问题”：一分钟后，context 自然会 Done。&lt;/p&gt;
&lt;p&gt;但问题在于：&lt;strong&gt;在这一分钟之内，这条 context 链路始终是“活的”&lt;/strong&gt; 。&lt;/p&gt;
&lt;p&gt;如果这是一个高频调用的函数，那你实际上是在不断地创建：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;定时器&lt;/li&gt;
&lt;li&gt;goroutine&lt;/li&gt;
&lt;li&gt;等待被取消的上下文&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;而这些东西，本来是可以更早被回收的。&lt;/p&gt;
&lt;p&gt;这也是为什么 Go 的习惯用法里，总会看到那句：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;defer cancel()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;它并不是为了“正常路径”，而是为了&lt;strong&gt;异常路径和提前返回&lt;/strong&gt;。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;另一种更隐蔽的泄漏，是​&lt;strong&gt;goroutine 没有正确监听 context&lt;/strong&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;ctx, cancel := context.WithCancel(context.Background())

go func() {
	for {
		doSomething()
	}
}()

cancel()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里即使你调用了 &lt;code&gt;cancel()&lt;/code&gt;​，context 也确实已经 Done 了，但这个 goroutine &lt;strong&gt;根本不知道这件事&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;从调用者的视角来看：我已经“取消”了&lt;/p&gt;
&lt;p&gt;但从 goroutine 的视角来看：我什么都没收到&lt;/p&gt;
&lt;p&gt;这时候泄漏的不是 context，而是&lt;strong&gt;本该结束的 goroutine&lt;/strong&gt;。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;还有一种我觉得更“工程化”的问题：&lt;strong&gt;context 的作用域被拉得过大。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;如果你：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;把一个请求级别的 context 存起来&lt;/li&gt;
&lt;li&gt;传给生命周期明显更长的后台任务&lt;/li&gt;
&lt;li&gt;或者挂到全局结构中复用&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;那这个 context 的取消时机，就已经和它所控制的 goroutine &lt;strong&gt;不匹配了&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;结果往往是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;该结束的没结束&lt;/li&gt;
&lt;li&gt;不该结束的被过早结束&lt;/li&gt;
&lt;li&gt;或者干脆谁也控制不了谁&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这种情况下，你很难说是“谁泄漏了”，但系统行为一定会开始变得不可预测。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;所以我现在理解的“context 泄漏”，并不是一个单点错误，而是一种&lt;strong&gt;协作失败的后果&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;它通常表现为：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;goroutine 数量悄悄增长&lt;/li&gt;
&lt;li&gt;请求已经结束，但后台还在工作&lt;/li&gt;
&lt;li&gt;程序没有明显报错，却越来越“不干净”&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;而根源往往很朴素：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;cancel 没有被调用&lt;/li&gt;
&lt;li&gt;Done 没有被监听&lt;/li&gt;
&lt;li&gt;生命周期边界画错了地方&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;十一、并发安全：不是所有地方都要锁&lt;/h2&gt;
&lt;h3&gt;48. data race 是怎么产生的&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;当前问题存在示例代码，可以前往&lt;a href=&quot;https://github.com/zxc7563598/go-lab/commit/cc377190183d903012f7f6366ff7fbb72b5ff8b5&quot;&gt;GitHub查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;在我一开始理解并发安全的时候，很容易把「data race」和「并发」本身混在一起，仿佛只要用了 goroutine，就天然和 data race 挂钩。&lt;/p&gt;
&lt;p&gt;但慢慢拆开来看，其实 data race 并不是「并发导致的问题」，而是&lt;strong&gt;并发访问共享数据时，缺乏明确约束&lt;/strong&gt;才出现的结果。&lt;/p&gt;
&lt;p&gt;更具体一点，一个 data race 出现，通常同时满足几个条件：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;同一块内存，被&lt;strong&gt;多个 goroutine 同时访问&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;至少有一次访问是&lt;strong&gt;写操作&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;这些访问之间，&lt;strong&gt;没有任何同步手段来建立顺序关系&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这几个条件里，「同时」并不是指物理意义上的绝对同时，而是 Go 运行时和 CPU 层面&lt;strong&gt;没有办法保证它们的执行顺序&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;哪怕在你眼里代码是顺序写的，只要没有同步原语，调度器就有权在任何时刻打断、切换、重排。&lt;/p&gt;
&lt;p&gt;这点和 PHP 的体验差异非常大。&lt;/p&gt;
&lt;p&gt;在 PHP 里，大多数时候请求是单线程模型：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;一个请求，一套内存&lt;/li&gt;
&lt;li&gt;变量生命周期和请求绑定&lt;/li&gt;
&lt;li&gt;几乎不会出现「两个执行单元同时改一个变量」&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以在 PHP 中，我很少需要显式去思考「某个变量正在被谁同时读写」。&lt;/p&gt;
&lt;p&gt;而在 Go 里，只要把一个变量暴露给多个 goroutine，这个问题就&lt;strong&gt;立刻成立&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;一个容易被忽略的点是：&lt;strong&gt;data race 和逻辑错误不是一回事。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;比如下面这种情况，从业务逻辑上看，结果「大概率」是对的，但它依然是 data race：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;多个 goroutine 同时对一个 &lt;code&gt;int&lt;/code&gt; 自增&lt;/li&gt;
&lt;li&gt;没有锁，也没有原子操作&lt;/li&gt;
&lt;li&gt;最终结果可能看起来「差不多正确」&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;问题不在于结果是不是偶尔对，而在于：&lt;strong&gt;Go 语言规范层面，已经不再对程序行为做任何保证&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;这也是为什么 Go 会有 race detector。&lt;/p&gt;
&lt;p&gt;它不是在帮你找“会不会出 bug”，而是在告诉你：这里的内存访问顺序是不被定义的&lt;/p&gt;
&lt;p&gt;换句话说，data race 本质上不是“线程安全没做好”，而是​&lt;strong&gt;程序已经越过了语言和运行时愿意为你兜底的边界&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;还有一个我后来才意识到的误区： &lt;strong&gt;“只读就没问题”并不总成立。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;如果一个变量在「初始化阶段」被写，之后只读，那么这是安全的；&lt;/p&gt;
&lt;p&gt;但如果是「某些 goroutine 在读，另一些 goroutine 偶尔在写」，哪怕写的频率很低，data race 依然成立。&lt;/p&gt;
&lt;p&gt;Go 并不会因为你“几乎不写”就宽容你。&lt;/p&gt;
&lt;p&gt;从这个角度看，data race 并不是一个实现层面的 bug，而更像是一种​&lt;strong&gt;设计层面的警告&lt;/strong&gt;：你现在对共享数据的所有权和访问时序，其实并没有想清楚。&lt;/p&gt;
&lt;p&gt;而后面要不要加锁、用 RWMutex、用 channel，甚至干脆复制数据，本质上都是在回答同一个问题：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;谁在什么时候，拥有什么数据的修改权。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;等这个问题明确了，data race 往往也就自然消失了。&lt;/p&gt;
&lt;h3&gt;49. mutex 与 RWMutex 的使用边界&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;当前问题存在示例代码，可以前往&lt;a href=&quot;https://github.com/zxc7563598/go-lab/commit/cc377190183d903012f7f6366ff7fbb72b5ff8b5&quot;&gt;GitHub查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;在理解了 data race 是「共享数据的访问顺序不再受语言保证」之后，mutex 的位置就变得清晰了一些：它并不是为了“让并发变安全”，而是&lt;strong&gt;人为地给并发访问加上一条明确的顺序&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;​&lt;code&gt;sync.Mutex&lt;/code&gt; 是最直接的一种方式。&lt;/p&gt;
&lt;p&gt;它的语义非常朴素：&lt;strong&gt;同一时间，只有一个 goroutine 能进入临界区&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;不管你是读还是写，只要进来了，别人就得等。&lt;/p&gt;
&lt;p&gt;这也是我一开始最容易接受的点：当我还没完全想清楚并发模型的时候，用 mutex，至少能保证「不会同时改」。&lt;/p&gt;
&lt;p&gt;但慢慢就会发现，mutex 的问题不在“能不能用”，而在于​&lt;strong&gt;它什么都管&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;读要等写&lt;/li&gt;
&lt;li&gt;读要等读&lt;/li&gt;
&lt;li&gt;哪怕只是看一眼状态，也要排队&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这时候 &lt;code&gt;RWMutex&lt;/code&gt; 看起来就很诱人了。&lt;/p&gt;
&lt;p&gt;它把访问拆成两类：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;​&lt;strong&gt;读锁（RLock）&lt;/strong&gt; ：多个 goroutine 可以同时持有&lt;/li&gt;
&lt;li&gt;​&lt;strong&gt;写锁（Lock）&lt;/strong&gt; ：独占，且会阻塞所有读&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;从模型上看，它表达了一种更精细的意图：&lt;strong&gt;这个共享数据，大多数时候只是被读取，只有少数时候会被修改&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;但真正用起来，我反而变得更谨慎了。&lt;/p&gt;
&lt;p&gt;原因并不是 RWMutex 有什么“坑”，而是它​&lt;strong&gt;对使用场景的要求非常严格&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;如果写操作并不罕见，或者读写比例本身并不稳定，那么 RWMutex 的优势会迅速消失，甚至可能比普通 Mutex 更差&lt;/p&gt;
&lt;p&gt;因为你付出了更复杂的锁管理成本，却没有换来足够的并发收益。&lt;/p&gt;
&lt;p&gt;更重要的是，RWMutex 在语义上其实放大了一个设计前提：&lt;strong&gt;你必须非常确定，哪些代码路径是“纯读”，哪些是“可能写”&lt;/strong&gt; 。&lt;/p&gt;
&lt;p&gt;一旦这个判断出错，比如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;你以为是读，但内部偷偷改了状态&lt;/li&gt;
&lt;li&gt;读操作依赖某个「可能被写」的复合结构&lt;/li&gt;
&lt;li&gt;写操作被拆散在多个函数里，不容易整体加锁&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;那么 RWMutex 带来的不是性能提升，而是​&lt;strong&gt;理解成本和出错概率的上升&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;所以在我的理解里：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Mutex 更像是“保守但稳妥”的选择&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;它牺牲了一部分并发性，但换来了更简单、直接的心智模型。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;RWMutex 更像是一种“性能假设成立时的优化手段”&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;而不是默认选项。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;还有一个让我重新看待 RWMutex 的点是：&lt;strong&gt;它并不会帮你解决“谁拥有数据”的问题，只是把锁分成了两种。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;如果你已经搞不清楚：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;这个数据到底被谁负责修改&lt;/li&gt;
&lt;li&gt;写发生在什么生命周期阶段&lt;/li&gt;
&lt;li&gt;是否真的存在“长期只读”的稳定状态&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;那么引入 RWMutex，往往只是把模糊的边界变得更复杂。&lt;/p&gt;
&lt;p&gt;反过来看，当我能清楚地说出：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;初始化阶段：写&lt;/li&gt;
&lt;li&gt;运行阶段：只读&lt;/li&gt;
&lt;li&gt;偶发配置更新：集中写&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这种情况下，RWMutex 才更像是在​&lt;strong&gt;把已有的设计事实编码进同步原语里&lt;/strong&gt;，而不是靠它来“补救并发安全”。&lt;/p&gt;
&lt;p&gt;所以对我来说，mutex 和 RWMutex 的使用边界，并不是性能数字，而是一个更偏设计的问题：&lt;strong&gt;我是否真的理解了这份数据在并发环境下的生命周期和访问模式。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;如果答案还是否定的，那越简单的锁，反而越诚实。&lt;/p&gt;
&lt;h3&gt;50. sync.Once 的实际应用场景&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;当前问题存在示例代码，可以前往&lt;a href=&quot;https://github.com/zxc7563598/go-lab/commit/cc377190183d903012f7f6366ff7fbb72b5ff8b5&quot;&gt;GitHub查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;在第一次看到 &lt;code&gt;sync.Once&lt;/code&gt;​ 的时候，我其实有点疑惑：这个东西看起来很“窄”，好像只解决一个非常具体的问题：&lt;strong&gt;只执行一次&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;但真正用过之后才发现，它解决的并不是“次数问题”，而是&lt;strong&gt;并发环境下的初始化边界问题&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;很多并发问题，本质都集中在「第一次」：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;第一次创建连接池&lt;/li&gt;
&lt;li&gt;第一次加载配置&lt;/li&gt;
&lt;li&gt;第一次初始化某个全局结构&lt;/li&gt;
&lt;li&gt;第一次启动后台 goroutine&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;在单线程世界里，这些事情天然是顺序发生的；&lt;/p&gt;
&lt;p&gt;但在 Go 里，只要有多个 goroutine 同时进来，“第一次”本身就变成了一个竞争点。&lt;/p&gt;
&lt;p&gt;一个直觉做法是用 mutex 包起来：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;判断是否初始化&lt;/li&gt;
&lt;li&gt;如果没有，就初始化&lt;/li&gt;
&lt;li&gt;然后释放锁&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;逻辑上没错，但这个方案里，其实你在&lt;strong&gt;手动维护一个状态机&lt;/strong&gt;：“有没有初始化过”，以及“现在谁有权初始化”。&lt;/p&gt;
&lt;p&gt;​&lt;code&gt;sync.Once&lt;/code&gt;​ 把这件事直接抽象成了一种语义：无论多少 goroutine 调用，传进去的函数，​&lt;strong&gt;只会被执行一次&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;而且更重要的是，这个“一次”是&lt;strong&gt;并发安全、并且带有内存可见性保证的&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;也就是说，Once 内部不仅解决了“只跑一次”，还顺手帮你解决了「初始化完成后，其他 goroutine 能不能看到完整结果」这个问题。&lt;/p&gt;
&lt;p&gt;我后来意识到，Once 适合的并不是“所有只能跑一次的逻辑”，而是​&lt;strong&gt;初始化型逻辑&lt;/strong&gt;，尤其是这几类：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;全局或包级资源的延迟初始化&lt;/strong&gt;&lt;br&gt;
比如某个 client、缓存、配置对象，不希望在 &lt;code&gt;init()&lt;/code&gt; 里就加载，但又必须保证只初始化一次。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;成本高、但并不一定会用到的准备工作&lt;/strong&gt;&lt;br&gt;
与其启动时全做，不如等第一次真的需要的时候再做，但又不能并发重复做。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;需要对外暴露一个“随时可用”的接口&lt;/strong&gt;&lt;br&gt;
调用方不需要关心你是否初始化过，只要用就行。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;在这些场景下，Once 表达的不是“控制流程”，而是​&lt;strong&gt;一种所有权声明&lt;/strong&gt;：初始化这件事，不属于任何一个具体 goroutine，而由 Once 统一负责。&lt;/p&gt;
&lt;p&gt;同时，它也隐含了一个非常重要的边界：&lt;strong&gt;Once 只负责“执行一次”，不负责“是否成功”。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;如果初始化函数里出错了：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Once 仍然认为“已经执行过”&lt;/li&gt;
&lt;li&gt;后续调用不会再重试&lt;/li&gt;
&lt;li&gt;错误处理必须由你自己设计&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这点让我在使用 Once 时变得格外谨慎。&lt;/p&gt;
&lt;p&gt;它更适合那种「要么成功，要么直接 panic / fail fast」的初始化，而不太适合需要复杂重试策略的场景。&lt;/p&gt;
&lt;p&gt;还有一个容易被忽略的点是：&lt;strong&gt;Once 并不是用来替代锁的。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;它解决的是「第一次」，而不是「每一次」。&lt;/p&gt;
&lt;p&gt;Once 之后的数据访问，如果仍然存在并发读写，锁依然是锁，channel 依然是 channel。&lt;/p&gt;
&lt;p&gt;所以在我的理解里，&lt;code&gt;sync.Once&lt;/code&gt; 的实际价值不在于“少写几行代码”，而在于它帮我把一个模糊的并发问题，收敛成了一句话就能说明白的设计事实：有些事情，在并发系统里，应该只发生一次，而且不属于任何人。&lt;/p&gt;
&lt;p&gt;当这个事实成立的时候，Once 往往是最直接、也最不容易被误用的表达方式。&lt;/p&gt;
&lt;h3&gt;51. 用 channel 替代锁的设计思路&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;当前问题存在示例代码，可以前往&lt;a href=&quot;https://github.com/zxc7563598/go-lab/commit/cc377190183d903012f7f6366ff7fbb72b5ff8b5&quot;&gt;GitHub查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;一开始接触 Go 的并发模型时，很容易把 channel 当成一种“更高级的锁”：不用显式加解锁，看起来更优雅，好像也更 Go。&lt;/p&gt;
&lt;p&gt;但后来我慢慢意识到，这样理解反而会把 channel 用窄了。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;channel 并不是在“保护共享数据”，而是在尝试让共享本身消失。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;锁的出发点是：数据是共享的，我们需要约束谁什么时候能碰它。&lt;/p&gt;
&lt;p&gt;而 channel 的出发点更像是：如果数据不共享了，那还需要锁吗？&lt;/p&gt;
&lt;p&gt;这两种思路，看起来只是实现不同，本质上却是​&lt;strong&gt;所有权模型的差异&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;用 mutex 时，常见的结构是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;多个 goroutine&lt;/li&gt;
&lt;li&gt;共同持有一份状态&lt;/li&gt;
&lt;li&gt;通过锁来协调访问顺序&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;而用 channel 的时候，更倾向于变成：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;某一份状态，&lt;strong&gt;只属于某一个 goroutine&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;其他 goroutine 只能通过发送消息，间接影响它&lt;/li&gt;
&lt;li&gt;状态的修改，被串行化在这个 goroutine 内部&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这里真正发生变化的不是“有没有并发”，而是：&lt;strong&gt;谁有权直接读写数据。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;当我把 channel 用在这种模型里时，它更像是在表达一个事实：这份数据的生命周期和一致性，由一个 goroutine 负责。&lt;/p&gt;
&lt;p&gt;而 channel，只是这个 goroutine 对外暴露的沟通接口。&lt;/p&gt;
&lt;p&gt;这也是为什么，用 channel 替代锁，往往不是简单地“把 mutex 换成 channel”，而是需要连带着调整结构设计。&lt;/p&gt;
&lt;p&gt;如果状态本身仍然被多个 goroutine 随意访问，那 channel 只会变成另一种形式的共享，而不是解法。&lt;/p&gt;
&lt;p&gt;一个我后来比较认可的判断标准是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;如果你关注的是​&lt;strong&gt;状态一致性&lt;/strong&gt;，锁更直接&lt;/li&gt;
&lt;li&gt;如果你关注的是​&lt;strong&gt;状态所有权和流转&lt;/strong&gt;，channel 更自然&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;比如在一些场景里：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;任务队列&lt;/li&gt;
&lt;li&gt;事件分发&lt;/li&gt;
&lt;li&gt;后台状态机&lt;/li&gt;
&lt;li&gt;聚合统计（计数、累积）&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;用 channel 的时候，你描述的是「事情发生了」，而不是「我现在要改一个变量」。&lt;/p&gt;
&lt;p&gt;当然，这种设计也不是没有代价。&lt;/p&gt;
&lt;p&gt;用 channel 往往意味着：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;状态被集中在某个 goroutine&lt;/li&gt;
&lt;li&gt;调试路径可能更“绕”&lt;/li&gt;
&lt;li&gt;需要更清楚地设计消息结构和生命周期&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果只是为了避免加锁，而强行引入 channel，反而可能让系统更难理解。&lt;/p&gt;
&lt;p&gt;所以对我来说，「用 channel 替代锁」并不是一个技巧，而是一种&lt;strong&gt;设计取向&lt;/strong&gt;的选择：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;我是希望继续接受“共享状态”的事实，只是把访问变安全&lt;/li&gt;
&lt;li&gt;还是愿意为此调整结构，把共享变成消息，把并发变成顺序&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;当后者成立时，channel 才真的发挥了它的价值。&lt;/p&gt;
&lt;p&gt;否则，它和 mutex 一样，都只是工具而已。&lt;/p&gt;
&lt;h3&gt;52. 复制数据 vs 加锁的取舍&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;当前问题存在示例代码，可以前往&lt;a href=&quot;https://github.com/zxc7563598/go-lab/commit/cc377190183d903012f7f6366ff7fbb72b5ff8b5&quot;&gt;GitHub查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;在并发问题里，「复制数据 vs 加锁」经常被摆在一起对比，好像是在做一次性能或优雅度的选择。&lt;/p&gt;
&lt;p&gt;但我后来慢慢意识到，这个取舍点其实并不完全在“技术层面”，而是在&lt;strong&gt;你愿不愿意为数据付出边界成本&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;加锁的前提是一个默认事实：&lt;strong&gt;这份数据是共享的，而且会被持续共享下去&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;复制数据则隐含了另一个判断：&lt;strong&gt;在某个时刻之后，这份数据不再需要被共同修改&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;这两种假设一旦不同，后续的设计方向基本就已经定了。&lt;/p&gt;
&lt;p&gt;用锁的时候，你关心的是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;锁粒度够不够小&lt;/li&gt;
&lt;li&gt;读写比例是否合适&lt;/li&gt;
&lt;li&gt;是否会形成竞争、阻塞、甚至死锁&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;而选择复制的时候，关注点会整体前移：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;数据是不是足够小，或者可控&lt;/li&gt;
&lt;li&gt;修改是否是阶段性的，而不是持续发生&lt;/li&gt;
&lt;li&gt;下游是否真的需要“实时一致”的状态&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;我一开始会下意识地倾向于“少复制，多共享”，这大概是受了传统后端经验的影响：&lt;strong&gt;复制看起来像是在浪费内存，而锁看起来只是“多了一点同步”&lt;/strong&gt; 。&lt;/p&gt;
&lt;p&gt;但在 Go 的并发语境下，这种直觉经常是反过来的。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;锁的成本并不只是运行时的开销&lt;/strong&gt;，还有持续存在的心智负担，每一个访问点，都要记得：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;我现在是不是在锁内&lt;/li&gt;
&lt;li&gt;会不会和别的 goroutine 形成交叉&lt;/li&gt;
&lt;li&gt;这个函数能不能被复用到别的上下文&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;而复制数据，往往是一次性的复杂度：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;在边界处做一次 copy&lt;/li&gt;
&lt;li&gt;后面的逻辑可以当作单线程来写&lt;/li&gt;
&lt;li&gt;不再需要为并发关系兜底&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这也是为什么在一些场景里，复制反而显得更“便宜”。&lt;/p&gt;
&lt;p&gt;尤其是这几种情况：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;请求级数据&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;每个请求一份，互不影响，本来就不该共享。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;配置 / 快照类数据&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;更新不频繁，读很多，但读并不要求实时同步。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;只读视图&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;下游只关心当前状态，不关心后续变化。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;在这些地方，用复制明确切断并发关系，比精细地加锁要更直接。&lt;/p&gt;
&lt;p&gt;当然，复制也不是没有边界。&lt;/p&gt;
&lt;p&gt;当数据满足以下特征时，锁通常更合理：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;体量很大，复制成本不可接受&lt;/li&gt;
&lt;li&gt;更新频繁，几乎每次都要保持一致&lt;/li&gt;
&lt;li&gt;数据天然就是一个“中心状态”，无法拆散&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这时候，共享几乎是事实，锁只是对事实的承认。&lt;/p&gt;
&lt;p&gt;所以我后来不太把「复制 vs 加锁」看成性能对比，而更像是在问一个设计问题：&lt;strong&gt;这份数据，真的需要被持续共享吗？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;如果答案是否定的，那复制往往是一次性买断复杂度；&lt;/p&gt;
&lt;p&gt;如果答案是肯定的，那锁的存在就是不可避免的长期成本。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;十二、并发错误：必须亲手踩过的坑&lt;/h2&gt;
&lt;h3&gt;53. goroutine 泄漏的几种常见写法&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;当前问题存在示例代码，可以前往&lt;a href=&quot;https://github.com/zxc7563598/go-lab/commit/5a3667ef8485a0be3fb60b4c6a4b62caec82fa51&quot;&gt;GitHub查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;在我开始系统性地意识到 goroutine 泄漏这件事之前，其实很长一段时间里，我只是隐约感觉到：&lt;strong&gt;有些 goroutine 好像“没人管了”&lt;/strong&gt; 。&lt;/p&gt;
&lt;p&gt;它们不报错，也不影响主流程，但它们确实还活着。&lt;/p&gt;
&lt;p&gt;下面这些写法，都是我后来回头看时，能明确意识到“这里已经具备泄漏条件”的例子。&lt;/p&gt;
&lt;p&gt;最基础的一种，是等待一个永远不会再发生的接收。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;package main

import (
    &quot;fmt&quot;
    &quot;time&quot;
)

func worker(ch chan int) {
    for {
        v := &amp;#x3C;-ch
        fmt.Println(&quot;received:&quot;, v)
    }
}

func main() {
    ch := make(chan int)

    go worker(ch)

    // 主 goroutine 什么都不做，只是等待一会儿退出
    time.Sleep(2 * time.Second)
    fmt.Println(&quot;main exit&quot;)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这段程序可以正常结束，&lt;code&gt;go run&lt;/code&gt; 也不会报任何错误。&lt;/p&gt;
&lt;p&gt;但在 &lt;code&gt;main&lt;/code&gt;​ 退出之前，&lt;code&gt;worker&lt;/code&gt;​ 已经卡在 &lt;code&gt;&amp;#x3C;-ch&lt;/code&gt; 上了。&lt;/p&gt;
&lt;p&gt;这里的关键不在于有没有循环，而在于：&lt;strong&gt;这个 goroutine 的退出条件完全依赖于一个外部假设：有人会往&lt;/strong&gt; &lt;strong&gt;​&lt;code&gt;ch&lt;/code&gt;​&lt;/strong&gt;​ &lt;strong&gt;写数据或者关闭它&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;一旦这个假设不成立，它就失去了返回的可能。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;稍微“进阶”一点的写法，是使用 &lt;code&gt;range ch&lt;/code&gt; 的消费者。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;package main

import (
    &quot;fmt&quot;
    &quot;time&quot;
)

func consumer(ch &amp;#x3C;-chan int) {
    for v := range ch {
        fmt.Println(&quot;consume:&quot;, v)
    }
    fmt.Println(&quot;consumer exit&quot;)
}

func main() {
    ch := make(chan int)

    go consumer(ch)

    ch &amp;#x3C;- 1
    ch &amp;#x3C;- 2

    // 没有 close(ch)
    time.Sleep(2 * time.Second)
    fmt.Println(&quot;main exit&quot;)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;从语义上看，这已经比直接 &lt;code&gt;&amp;#x3C;-ch&lt;/code&gt;​ 安全得多，因为 &lt;code&gt;range&lt;/code&gt; 是“为关闭而生”的。&lt;/p&gt;
&lt;p&gt;但问题依然存在：&lt;strong&gt;如果没有人负责关闭这个 channel，这个 goroutine 就永远不会走到循环外。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;你甚至已经写好了退出逻辑（&lt;code&gt;consumer exit&lt;/code&gt;），只是它永远不会被触发。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;另一类我后来觉得非常“隐蔽”的，是带 &lt;code&gt;default&lt;/code&gt;​ 的 &lt;code&gt;select&lt;/code&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;package main

import (
    &quot;fmt&quot;
    &quot;time&quot;
)

func loop(ch &amp;#x3C;-chan int) {
    for {
        select {
        case v := &amp;#x3C;-ch:
            fmt.Println(&quot;received:&quot;, v)
        default:
            // 看起来很安全：不阻塞
        }
    }
}

func main() {
    ch := make(chan int)

    go loop(ch)

    time.Sleep(2 * time.Second)
    fmt.Println(&quot;main exit&quot;)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这段代码里，没有任何地方会“卡住”。&lt;/p&gt;
&lt;p&gt;相反，它会一直运行。&lt;/p&gt;
&lt;p&gt;问题在于：&lt;/p&gt;
&lt;p&gt;这个 goroutine 没有任何&lt;strong&gt;退出条件&lt;/strong&gt;，它只是在不断地证明自己还活着。&lt;/p&gt;
&lt;p&gt;如果你在 &lt;code&gt;default&lt;/code&gt;​ 里加点日志，很快就会意识到这不是“安全”，而是​&lt;strong&gt;失控的常驻循环&lt;/strong&gt;。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;还有一类问题，其实和 channel、select 都没关系，而是“把 goroutine 当成一次性异步函数”。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;package main

import (
    &quot;fmt&quot;
    &quot;time&quot;
)

func asyncTask() {
    for {
        fmt.Println(&quot;working...&quot;)
        time.Sleep(500 * time.Millisecond)
    }
}

func main() {
    go asyncTask()

    time.Sleep(2 * time.Second)
    fmt.Println(&quot;main exit&quot;)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在阅读这段代码的时候，很容易下意识地理解为：我异步执行了一个任务，主流程结束就结束了。&lt;/p&gt;
&lt;p&gt;但真实情况是：​​&lt;code&gt;asyncTask&lt;/code&gt;​ 本身是一个&lt;strong&gt;永远不会返回的函数&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;一旦你在一个长期运行的服务中这样写，你就已经创建了一个永久 goroutine，只是没有给它任何边界。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;到这里，我对 goroutine 泄漏的判断已经不太依赖“有没有 bug”，而更多依赖一个问题：&lt;strong&gt;如果我现在关掉调用方，这个 goroutine 会不会在设计上自然结束？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;如果答案需要附带很多前提条件，那它大概率已经站在泄漏的边缘了。&lt;/p&gt;
&lt;h3&gt;54. channel 永远阻塞的原因&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;当前问题存在示例代码，可以前往&lt;a href=&quot;https://github.com/zxc7563598/go-lab/commit/5a3667ef8485a0be3fb60b4c6a4b62caec82fa51&quot;&gt;GitHub查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;在我真正理解 channel 为什么会“永远阻塞”之前，其实有一个挺大的心理落差：&lt;strong&gt;channel 在语义上看起来是“通信工具”，但在行为上，它更像是一种非常严格的同步协议。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;阻塞并不是异常，而是它的默认行为。&lt;/p&gt;
&lt;p&gt;真正需要解释的反而是：&lt;strong&gt;为什么我们会在某些场景下，以为它不该阻塞。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;最直接的一种情况，是最基础的发送 / 接收不对等。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;package main

import (
    &quot;fmt&quot;
)

func main() {
    ch := make(chan int)

    ch &amp;#x3C;- 1

    fmt.Println(&quot;unreachable&quot;)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这段代码在逻辑上非常“直白”：我创建一个 channel，然后往里面塞一个值。&lt;/p&gt;
&lt;p&gt;但它会直接卡死在 &lt;code&gt;ch &amp;#x3C;- 1&lt;/code&gt; 这一行。&lt;/p&gt;
&lt;p&gt;原因并不复杂：&lt;strong&gt;无缓冲 channel 的发送，必须等到有人接收才能完成。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;如果站在 channel 的视角，这并不是“没人理我”，而是：我在等一个明确的交接时刻，但你从未安排过对方。&lt;/p&gt;
&lt;p&gt;这里的阻塞不是偶发的，而是​&lt;strong&gt;必然的&lt;/strong&gt;。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;稍微变一下形式，把接收放到 goroutine 里，阻塞的感觉就没那么直观了。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;package main

import (
    &quot;fmt&quot;
)

func main() {
    ch := make(chan int)

    go func() {
        v := &amp;#x3C;-ch
        fmt.Println(&quot;received:&quot;, v)
    }()

    // 主 goroutine 提前结束
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这段代码不会 panic，也不会报错，但什么都不会输出。&lt;/p&gt;
&lt;p&gt;问题不在 channel，而在调度顺序和生命周期上：主 goroutine 很快就结束了，整个进程随之退出，接收方根本没有机会运行。&lt;/p&gt;
&lt;p&gt;如果你在某个服务型程序里写了类似结构，就会得到另一种结果：&lt;strong&gt;接收 goroutine 活着，但发送永远没发生，或者反过来。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;从外部看，它们都只是“在等”。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;另一种我一开始没太当回事，但后来发现非常容易写出来的情况，是 &lt;code&gt;range channel&lt;/code&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;package main

import (
    &quot;fmt&quot;
)

func main() {
    ch := make(chan int)

    go func() {
        for v := range ch {
            fmt.Println(v)
        }
        fmt.Println(&quot;exit&quot;)
    }()

    ch &amp;#x3C;- 1
    ch &amp;#x3C;- 2

    // 没有 close(ch)
    select {}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这段代码的阻塞点并不在发送，而在接收。&lt;/p&gt;
&lt;p&gt;​&lt;code&gt;range ch&lt;/code&gt;​ 的语义非常清晰：&lt;strong&gt;一直读，直到 channel 被关闭。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;问题在于，如果你只是“停止发送”，但没有关闭 channel，那么在 channel 看来，世界并没有结束，它只是暂时没数据。&lt;/p&gt;
&lt;p&gt;于是接收方就会一直阻塞在等待下一次发送上。&lt;/p&gt;
&lt;p&gt;这里很容易出现一种错觉：“我已经不往里写了，为什么还不结束？”&lt;/p&gt;
&lt;p&gt;因为对 channel 来说，“不再写”和“生命周期结束”是两件完全不同的事。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;再往下看，就会遇到一些更“像业务代码”的阻塞。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;package main

import (
    &quot;fmt&quot;
)

func main() {
    ch := make(chan int)

    select {
    case ch &amp;#x3C;- 1:
        fmt.Println(&quot;sent&quot;)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这段代码里甚至没有接收方，但 &lt;code&gt;select&lt;/code&gt; 的存在会让人误以为：“它至少不会一直卡着吧？”&lt;/p&gt;
&lt;p&gt;但 &lt;code&gt;select&lt;/code&gt; 并不会创造奇迹。&lt;/p&gt;
&lt;p&gt;如果所有 case 都无法继续，它的行为和普通阻塞是完全一致的。&lt;/p&gt;
&lt;p&gt;这里没有 &lt;code&gt;default&lt;/code&gt;​，所以 &lt;code&gt;select&lt;/code&gt;​ 的含义其实是：&lt;strong&gt;我愿意在这里等，直到某个 case 可以执行。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;而在这个程序里，这一天永远不会到来。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;还有一种阻塞，是在你“觉得自己已经考虑周全”的情况下发生的。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;package main

import (
    &quot;fmt&quot;
)

func main() {
    ch := make(chan int, 1)

    ch &amp;#x3C;- 1
    ch &amp;#x3C;- 2

    fmt.Println(&quot;done&quot;)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;很多人第一次用有缓冲 channel 时，会把它理解成：“有点像队列，多塞几个应该没问题。”&lt;/p&gt;
&lt;p&gt;但缓冲只是在&lt;strong&gt;容量范围内&lt;/strong&gt;改变阻塞时机，并没有取消阻塞这个概念。&lt;/p&gt;
&lt;p&gt;当缓冲满了，发送依然是同步的。&lt;/p&gt;
&lt;p&gt;这段代码卡死在第二次发送上，其实是在提醒一件事：&lt;strong&gt;channel 不是消息系统，它只是一个带容量的同步点。&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;慢慢把这些情况放在一起看，我对“channel 永远阻塞”的理解反而变简单了。&lt;/p&gt;
&lt;p&gt;它从来不是“偶然卡住”，而几乎总是因为下面这类原因之一：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;发送和接收在数量或时序上不对等&lt;/li&gt;
&lt;li&gt;接收方在等一个永远不会被 close 的 channel&lt;/li&gt;
&lt;li&gt;​&lt;code&gt;select&lt;/code&gt; 里没有任何可能继续的 case&lt;/li&gt;
&lt;li&gt;缓冲被当成了“无限容量”的错觉&lt;/li&gt;
&lt;li&gt;goroutine 的生命周期比 channel 短或长，但设计时没对齐&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;channel 本身并不会判断你“是不是该结束了”。&lt;/p&gt;
&lt;p&gt;它只会严格执行你写下的同步协议。&lt;/p&gt;
&lt;p&gt;当你感觉它“永远阻塞”的时候，往往不是 channel 出了问题，而是：&lt;strong&gt;你其实已经写出了一个“永远等下去也合理”的程序。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;55. for + goroutine 的经典陷阱&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;当前问题存在示例代码，可以前往&lt;a href=&quot;https://github.com/zxc7563598/go-lab/commit/5a3667ef8485a0be3fb60b4c6a4b62caec82fa51&quot;&gt;GitHub查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;现在再看 &lt;code&gt;for + goroutine&lt;/code&gt;，我已经很少把注意力放在“循环变量对不对”这种层面了。语言已经把这件事处理得足够符合直觉，反而是另外一些问题，被这个组合悄悄放大了。&lt;/p&gt;
&lt;p&gt;第一个让我警觉的点，是​&lt;strong&gt;启动了，但没人等&lt;/strong&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;package main

import (
    &quot;fmt&quot;
)

func main() {
    for i := 0; i &amp;#x3C; 5; i++ {
        go func(i int) {
            fmt.Println(i)
        }(i)
    }

    fmt.Println(&quot;main exit&quot;)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这段代码在语义上完全正确：值是对的，goroutine 也确实被启动了。&lt;/p&gt;
&lt;p&gt;但从结构上看，它其实表达的是一件很模糊的事：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;这些 goroutine 是否“重要”，以及它们是否需要完成，没有被代码回答。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;​&lt;code&gt;for + goroutine&lt;/code&gt; 非常容易让并发变成一种“顺手就写了”的行为，而不是一个被明确建模的流程。&lt;/p&gt;
&lt;p&gt;一旦你没有显式等待，它们就和调用方脱钩了。&lt;/p&gt;
&lt;p&gt;在示例里，程序很快结束；&lt;/p&gt;
&lt;p&gt;在服务里，它们可能会在请求结束后继续运行，变成你并未计划的后台任务。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;第二个问题，是​&lt;strong&gt;共享外部状态被并发放大&lt;/strong&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;package main

import (
    &quot;fmt&quot;
    &quot;sync&quot;
)

func main() {
    var result []int
    var wg sync.WaitGroup

    for i := 0; i &amp;#x3C; 5; i++ {
        wg.Add(1)
        go func(i int) {
            defer wg.Done()
            result = append(result, i)
        }(i)
    }

    wg.Wait()
    fmt.Println(result)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里并没有任何“经典写法错误”：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;循环变量是安全的&lt;/li&gt;
&lt;li&gt;goroutine 也被正确等待了&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;但问题仍然存在，因为 &lt;code&gt;result&lt;/code&gt; 是共享的。&lt;/p&gt;
&lt;p&gt;for 循环的作用，在这里其实只是​&lt;strong&gt;把一个本来就不安全的操作，瞬间并发执行了很多次&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;这类问题特别容易被忽视，因为你会下意识觉得：我已经用 WaitGroup 了，结构是对的。&lt;/p&gt;
&lt;p&gt;但 WaitGroup 只解决了“什么时候结束”，并不解决“是否可以并发写”。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;第三个坑，往往出现在&lt;strong&gt;资源生命周期和 goroutine 生命周期错位&lt;/strong&gt;的时候。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;package main

import (
    &quot;fmt&quot;
    &quot;os&quot;
)

func main() {
    file, _ := os.Open(&quot;test.txt&quot;)
    defer file.Close()

    for i := 0; i &amp;#x3C; 3; i++ {
        go func(i int) {
            buf := make([]byte, 10)
            file.Read(buf)
            fmt.Println(i, string(buf))
        }(i)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这段代码里：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;​&lt;code&gt;file&lt;/code&gt;​ 的生命周期绑定在 &lt;code&gt;main&lt;/code&gt; 上&lt;/li&gt;
&lt;li&gt;goroutine 的执行时机是不确定的&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;for 循环结束得非常快，而 &lt;code&gt;defer file.Close()&lt;/code&gt;​ 会在 &lt;code&gt;main&lt;/code&gt; 返回时立刻执行。&lt;/p&gt;
&lt;p&gt;于是就出现了一种结构性问题：&lt;strong&gt;资源已经被回收了，但使用它的 goroutine 还没开始，或者还没用完。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这里没有语法错误，也没有明显的并发冲突，但程序行为已经变得不可预测了。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;再往后一个层次，是​&lt;strong&gt;并发规模被无意中放大&lt;/strong&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;for _, task := range tasks {
    go handle(task)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这行代码在今天的 Go 里，语义非常干净，也非常诱人。&lt;/p&gt;
&lt;p&gt;但它隐含了一个强假设：&lt;strong&gt;tasks 的规模是可控的，而且每个任务都适合同时执行。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;一旦：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;tasks 来自外部输入&lt;/li&gt;
&lt;li&gt;数量不可预期&lt;/li&gt;
&lt;li&gt;handle 内部阻塞或耗时&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这个 for 循环，本质上就是在​&lt;strong&gt;瞬间制造大量 goroutine&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;这里的问题不是“并发本身”，而是：for + goroutine 让“并发数量”这件事变得太不显眼了。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;所以现在我再看 for + goroutine，心里的判断标准已经很稳定了：&lt;/p&gt;
&lt;p&gt;循环变量是不是安全，已经不是重点；&lt;/p&gt;
&lt;p&gt;真正需要被回答的，是并发结构本身。&lt;/p&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;这些 goroutine 谁来等？&lt;/li&gt;
&lt;li&gt;它们是否在访问共享状态？&lt;/li&gt;
&lt;li&gt;它们依赖的资源是否还活着？&lt;/li&gt;
&lt;li&gt;并发的规模是否被明确限制？&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;56. 并发 map 写导致的 panic&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;当前问题存在示例代码，可以前往&lt;a href=&quot;https://github.com/zxc7563598/go-lab/commit/5a3667ef8485a0be3fb60b4c6a4b62caec82fa51&quot;&gt;GitHub查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;在我第一次遇到 &lt;strong&gt;并发 map 写 panic&lt;/strong&gt; 的时候，其实并没有太多“并发出错”的直觉。&lt;/p&gt;
&lt;p&gt;因为从代码结构上看，它往往并不复杂，甚至还挺直观。&lt;/p&gt;
&lt;p&gt;直到我意识到：&lt;strong&gt;这不是一个“写法问题”，而是 Go 在这里做了一个非常强硬、非常明确的选择。&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;先从一个最小、也最容易复现的例子开始。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;package main

import (
    &quot;sync&quot;
)

func main() {
    m := make(map[int]int)
    var wg sync.WaitGroup

    for i := 0; i &amp;#x3C; 10; i++ {
        wg.Add(1)
        go func(i int) {
            defer wg.Done()
            m[i] = i
        }(i)
    }

    wg.Wait()
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这段代码并不“偶发”出错，它几乎一定会 panic：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;fatal error: concurrent map writes
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里没有 data race 的模糊空间，Go 运行时直接中断了程序。&lt;/p&gt;
&lt;p&gt;这件事一开始让我挺不适应的，因为在很多语言里，这类问题的表现通常是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;数据错了&lt;/li&gt;
&lt;li&gt;偶尔崩&lt;/li&gt;
&lt;li&gt;或者什么都没发生，但结果不可信&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;而 Go 在这里的态度非常明确：&lt;strong&gt;一旦发现 map 被并发写，程序立刻终止。&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;关键在于：&lt;strong&gt;Go 的 map，从来就不是并发安全的数据结构。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;它内部会在写入时：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;扩容&lt;/li&gt;
&lt;li&gt;重排 bucket&lt;/li&gt;
&lt;li&gt;移动元素&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这些操作本身就假设“当前只有一个写者”。&lt;/p&gt;
&lt;p&gt;于是，一旦两个 goroutine 同时写 map，运行时与其让你得到一个“看起来还能用但已经损坏”的 map，不如直接告诉你：程序不成立。&lt;/p&gt;
&lt;p&gt;这并不是一个“性能取舍”，而是一种​&lt;strong&gt;设计态度&lt;/strong&gt;。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;更容易让人误判的，是下面这种“读写混合”的场景。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;package main

import (
    &quot;sync&quot;
)

func main() {
    m := make(map[int]int)
    var wg sync.WaitGroup

    for i := 0; i &amp;#x3C; 5; i++ {
        wg.Add(1)
        go func(i int) {
            defer wg.Done()
            _ = m[i]
        }(i)
    }

    for i := 5; i &amp;#x3C; 10; i++ {
        wg.Add(1)
        go func(i int) {
            defer wg.Done()
            m[i] = i
        }(i)
    }

    wg.Wait()
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;很多人第一次看到 &lt;code&gt;concurrent map writes&lt;/code&gt;，会误以为：是不是只有写写才不行，读写应该没事吧？&lt;/p&gt;
&lt;p&gt;但在 Go 里，只要存在​&lt;strong&gt;并发写&lt;/strong&gt;，不论是否混着读，行为就是未定义的，运行时也可能直接 panic。&lt;/p&gt;
&lt;p&gt;读本身是安全的，但​&lt;strong&gt;读和写并发出现时，map 的内部状态已经不再可控&lt;/strong&gt;。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;我后来意识到一个很重要的点：&lt;strong&gt;这个 panic 并不是在提醒你“少用并发”，而是在逼你做结构选择。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;一旦你决定让 map 出现在多个 goroutine 中，你就必须回答下面这些问题之一：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;是不是应该用锁？&lt;/li&gt;
&lt;li&gt;是不是应该把 map 的写集中到一个 goroutine？&lt;/li&gt;
&lt;li&gt;是不是应该换一种数据结构？&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Go 不会帮你在运行时“偷偷兜底”，而是要求你在设计时明确站队。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;比如最直接的方式，是显式加锁。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;package main

import (
    &quot;fmt&quot;
    &quot;sync&quot;
)

func main() {
    m := make(map[int]int)
    var mu sync.Mutex
    var wg sync.WaitGroup

    for i := 0; i &amp;#x3C; 10; i++ {
        wg.Add(1)
        go func(i int) {
            defer wg.Done()
            mu.Lock()
            m[i] = i
            mu.Unlock()
        }(i)
    }

    wg.Wait()
    fmt.Println(m)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这段代码本身并不“高级”，但它非常清楚地表达了一件事：&lt;strong&gt;这个 map 是共享的，而且我承认这件事。&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;另一种思路，是干脆不共享。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;package main

import (
    &quot;fmt&quot;
)

func main() {
    ch := make(chan int)
    m := make(map[int]int)

    go func() {
        for v := range ch {
            m[v] = v
        }
    }()

    for i := 0; i &amp;#x3C; 10; i++ {
        ch &amp;#x3C;- i
    }

    close(ch)
    fmt.Println(m)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里 map 只存在于一个 goroutine 里，并发发生在 channel 上，而不是数据结构本身。&lt;/p&gt;
&lt;p&gt;这类写法，本质上是在用结构规避问题，而不是“修补错误”。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;所以现在我再看到 ​&lt;strong&gt;并发 map 写导致的 panic&lt;/strong&gt;，已经不太会把它理解成“坑”了。&lt;/p&gt;
&lt;p&gt;它更像是一道非常明确的边界线：只要你想让 map 被并发写，你就必须为这个决定负责。&lt;/p&gt;
&lt;p&gt;panic 的存在，并不是 Go 太严格，而是它拒绝在数据结构已经不成立的情况下，继续假装程序还“能跑”。&lt;/p&gt;
&lt;p&gt;而这也正是 Go 并发模型里一个非常一致的态度：&lt;strong&gt;问题要么在设计阶段被处理掉，要么在运行时被明确拒绝。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;57. 如何通过结构设计避免并发 bug&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;当前问题存在示例代码，可以前往&lt;a href=&quot;https://github.com/zxc7563598/go-lab/commit/5a3667ef8485a0be3fb60b4c6a4b62caec82fa51&quot;&gt;GitHub查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;在把前面那些并发问题逐个拆开之后，我慢慢意识到一件事：&lt;strong&gt;大多数并发 bug，其实不是“哪里没加锁”，而是“结构一开始就没想清楚”。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;当我开始从“结构”而不是“修补”去看问题时，很多之前看起来零散的坑，反而能归到同一类原因里。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;我现在判断一个并发设计是否危险，通常先看一个问题：&lt;strong&gt;并发发生在哪里？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;如果并发发生在数据结构内部，那你接下来几乎一定会面对：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;锁&lt;/li&gt;
&lt;li&gt;竞态&lt;/li&gt;
&lt;li&gt;生命周期错位&lt;/li&gt;
&lt;li&gt;难以复现的问题&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以第一个结构层面的选择，是尽量把并发​&lt;strong&gt;推到边缘&lt;/strong&gt;，而不是让它渗透进核心数据。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;type Store struct {
    m map[string]int
}

func (s *Store) Set(k string, v int) {
    s.m[k] = v
}

func (s *Store) Get(k string) int {
    return s.m[k]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果这个 &lt;code&gt;Store&lt;/code&gt; 被多个 goroutine 直接调用，那并发已经侵入了最核心的状态。&lt;/p&gt;
&lt;p&gt;但如果换一个结构视角：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;type request struct {
    key   string
    value int
}

func storeLoop(reqCh &amp;#x3C;-chan request) {
    m := make(map[string]int)
    for req := range reqCh {
        m[req.key] = req.value
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;并发不再发生在 map 上，而是发生在 channel 的发送上。&lt;/p&gt;
&lt;p&gt;map 本身重新变成了“单线程世界”的东西。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;第二个我越来越在意的，是​&lt;strong&gt;明确 goroutine 的所有权&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;很多并发 bug，本质上都是因为某个 goroutine：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;由谁创建，不清楚&lt;/li&gt;
&lt;li&gt;由谁负责结束，也不清楚&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;一旦所有权模糊，生命周期就很容易失控。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;go worker()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这行代码本身什么都没说清楚：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;worker 是否重要？&lt;/li&gt;
&lt;li&gt;是否必须完成？&lt;/li&gt;
&lt;li&gt;什么时候结束？&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果换一种结构表达：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func startWorker(ctx context.Context) &amp;#x3C;-chan struct{} {
    done := make(chan struct{})
    go func() {
        defer close(done)
        for {
            select {
            case &amp;#x3C;-ctx.Done():
                return
            default:
                // do work
            }
        }
    }()
    return done
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里就非常明确了：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;谁创建 worker&lt;/li&gt;
&lt;li&gt;如何通知它退出&lt;/li&gt;
&lt;li&gt;如何知道它已经结束&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;不是因为多写了几行代码更“规范”，而是​&lt;strong&gt;结构上不再有模糊空间&lt;/strong&gt;。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;第三个结构层面的选择，是​&lt;strong&gt;让“等待”变成显式行为&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;​&lt;code&gt;for + goroutine&lt;/code&gt; 特别容易制造一种假象：事情已经开始了，就当它们会自己结束吧。&lt;/p&gt;
&lt;p&gt;但在稳定的并发结构里，等待几乎总是被明确表达出来的。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;var wg sync.WaitGroup

for _, task := range tasks {
    wg.Add(1)
    go func(task Task) {
        defer wg.Done()
        handle(task)
    }(task)
}

wg.Wait()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这段代码的意义，不只是“等所有 goroutine 结束”，而是在结构上宣告了一件事：&lt;strong&gt;这些任务是这个函数职责的一部分。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;一旦你不等，它们就已经不属于当前结构了。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;第四个我后来非常看重的点，是​&lt;strong&gt;限制并发规模&lt;/strong&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;for _, task := range tasks {
    go handle(task)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这段代码的问题不在并发，而在​&lt;strong&gt;无上限&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;结构上更稳定的方式，往往会显式引入“容量”的概念。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;sem := make(chan struct{}, 5)

for _, task := range tasks {
    sem &amp;#x3C;- struct{}{}
    go func(task Task) {
        defer func() { &amp;#x3C;-sem }()
        handle(task)
    }(task)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里并发是否发生，是被结构明确控制的，而不是被 for 循环顺手放大的。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;最后一个让我对并发 bug 看法发生变化的点，是：&lt;strong&gt;不要让并发和资源生命周期隐式耦合。&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func process(file *os.File) {
    go func() {
        file.Read(...)
    }()
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这段代码的问题，不是读文件，而是：&lt;code&gt;file&lt;/code&gt; 的生命周期并不由 goroutine 控制。&lt;/p&gt;
&lt;p&gt;结构更清晰的方式，往往会把资源和 goroutine 放在同一个边界内。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func process() {
    file, _ := os.Open(&quot;a.txt&quot;)
    defer file.Close()

    read(file)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;或者反过来，把 goroutine 的生命周期交给外部。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;所以现在再回头看并发 bug，我已经很少从“这里该不该加锁”开始思考了。&lt;/p&gt;
&lt;p&gt;我更常问的是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;并发是不是发生在我希望它发生的地方？&lt;/li&gt;
&lt;li&gt;数据是不是有且只有一个拥有者？&lt;/li&gt;
&lt;li&gt;goroutine 的生命周期是否被明确建模？&lt;/li&gt;
&lt;li&gt;等待、退出、容量，这些事情有没有被结构表达出来？&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;当这些问题在结构上已经被回答时，很多并发 bug，其实还没来得及出现，就已经被排除掉了。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;十三、Go Web 中的生命周期意识&lt;/h2&gt;
&lt;h3&gt;58. HTTP 请求在 Go 中的完整生命周期&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;当前问题存在示例代码，可以前往&lt;a href=&quot;https://github.com/zxc7563598/go-lab/commit/0fd5762d78f2fef05be7e82dce9689526546be2d&quot;&gt;GitHub查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;在 Go Web 里，一个 HTTP 请求的生命周期是非常直观的。&lt;/p&gt;
&lt;p&gt;服务启动后，&lt;code&gt;net/http&lt;/code&gt; 开始监听端口，每一个进入的连接都会被交给独立的 goroutine 处理，请求从一开始就处在并发环境中。&lt;/p&gt;
&lt;p&gt;一个最小的 HTTP 服务大概是这个样子：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;package main

import (
	&quot;fmt&quot;
	&quot;net/http&quot;
)

func main() {
	http.HandleFunc(&quot;/hello&quot;, helloHandler)
	fmt.Println(&quot;server start at :8080&quot;)
	http.ListenAndServe(&quot;:8080&quot;, nil)
}

func helloHandler(w http.ResponseWriter, r *http.Request) {
	fmt.Println(&quot;handler start&quot;)
	w.Write([]byte(&quot;hello&quot;))
	fmt.Println(&quot;handler end&quot;)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;启动这个程序，然后请求 &lt;code&gt;/hello&lt;/code&gt;，你能清楚地看到：&lt;/p&gt;
&lt;p&gt;​&lt;code&gt;handler start&lt;/code&gt; 打印时，请求刚刚进入你的代码；&lt;/p&gt;
&lt;p&gt;​&lt;code&gt;handler end&lt;/code&gt; 打印完，这次请求在你这边的生命周期就结束了。&lt;/p&gt;
&lt;p&gt;这里有一个很重要但容易被忽略的事实：&lt;strong&gt;​&lt;code&gt;helloHandler&lt;/code&gt;​&lt;/strong&gt;​ &lt;strong&gt;这一次函数调用，本身就是请求生命周期在你代码里的全部体现&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;Go 没有隐藏阶段，也没有额外的“请求对象生命周期管理”，你写的函数，就是边界。&lt;/p&gt;
&lt;p&gt;当请求进入 handler 时，&lt;code&gt;net/http&lt;/code&gt;​ 已经为你准备好了一个 &lt;code&gt;*http.Request&lt;/code&gt;​，而这个 request 上，绑定着一个非常关键的东西：&lt;code&gt;Context&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;我们把上面的例子稍微扩展一下，让 context 参与进来：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;package main

import (
	&quot;context&quot;
	&quot;fmt&quot;
	&quot;net/http&quot;
	&quot;time&quot;
)

func main() {
	http.HandleFunc(&quot;/work&quot;, workHandler)
	fmt.Println(&quot;server start at :8080&quot;)
	http.ListenAndServe(&quot;:8080&quot;, nil)
}

func workHandler(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()

	result := doWork(ctx)

	w.Write([]byte(result))
}

func doWork(ctx context.Context) string {
	select {
	case &amp;#x3C;-time.After(3 * time.Second):
		return &quot;work done&quot;
	case &amp;#x3C;-ctx.Done():
		return &quot;request canceled&quot;
	}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;现在访问 &lt;code&gt;/work&lt;/code&gt;​，如果你在 3 秒内主动断开连接（比如浏览器刷新或关闭），&lt;code&gt;doWork&lt;/code&gt;​ 会立刻走到 &lt;code&gt;ctx.Done()&lt;/code&gt;​ 分支。&lt;/p&gt;
&lt;p&gt;这件事非常“Go”：&lt;strong&gt;请求并不是“一定会跑完”的，你的代码必须承认这一点。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;在 PHP 的同步执行模型里，请求结束通常意味着脚本自然跑完；而在 Go 里，请求可以先结束，但 goroutine 仍然活着，只是 context 明确告诉你：这件事已经不值得继续做了。&lt;/p&gt;
&lt;p&gt;当 &lt;code&gt;workHandler&lt;/code&gt;​ 返回时，对 Go 来说，这次请求就已经结束了。响应被写出，连接可能被复用，而 &lt;code&gt;*http.Request&lt;/code&gt;​、&lt;code&gt;ResponseWriter&lt;/code&gt; 都不再属于你。&lt;/p&gt;
&lt;p&gt;这时如果我们引入一个常见但危险的写法，生命周期的问题就会立刻暴露出来：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;package main

import (
	&quot;fmt&quot;
	&quot;net/http&quot;
	&quot;time&quot;
)

func main() {
	http.HandleFunc(&quot;/async&quot;, asyncHandler)
	fmt.Println(&quot;server start at :8080&quot;)
	http.ListenAndServe(&quot;:8080&quot;, nil)
}

func asyncHandler(w http.ResponseWriter, r *http.Request) {
	go func() {
		time.Sleep(2 * time.Second)
		fmt.Println(&quot;async work done&quot;)
	}()

	w.Write([]byte(&quot;response sent&quot;))
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个程序当然是能跑的，但它在语义上已经开始模糊请求的生命周期了：handler 已经返回，响应已经发出，但 goroutine 仍然在后台执行，而且它和这次 HTTP 请求已经没有任何正式的关系。&lt;/p&gt;
&lt;p&gt;如果这个 goroutine 里还在使用 &lt;code&gt;r&lt;/code&gt;​、&lt;code&gt;w&lt;/code&gt;​、或者假设“这是一次合法的请求上下文”，那问题就不再是写法不优雅，而是​&lt;strong&gt;生命周期已经被破坏了&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;回过头来看，Go 中 HTTP 请求的完整生命周期其实非常短：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;handler 被调用，请求进入你的代码&lt;/li&gt;
&lt;li&gt;handler 返回，请求在你这里结束&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;中间所有你认为“顺理成章”的事情——数据库连接、goroutine、缓存、异步任务——都必须主动地对齐这个时间窗口。&lt;/p&gt;
&lt;p&gt;所以这一节对我来说，更像是在建立一个认知前提：&lt;strong&gt;在 Go Web 里，请求不是一个模糊的过程，而是一段你必须明确尊重的生命周期。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;59. handler、middleware、service 的职责边界&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;当前问题存在示例代码，可以前往&lt;a href=&quot;https://github.com/zxc7563598/go-lab/commit/0fd5762d78f2fef05be7e82dce9689526546be2d&quot;&gt;GitHub查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;这一节在我看来，其实不是在讲“该怎么分层”，而是在回答一个更底层的问题：&lt;strong&gt;在 Go Web 里，谁对 HTTP 请求的生命周期负责到哪一步为止。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;如果把请求当成一条时间线，那 handler、middleware、service 并不是三个并列的技术名词，而是​&lt;strong&gt;对这条时间线不同区段的认领方式&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;先从 handler 说起。&lt;/p&gt;
&lt;p&gt;在 Go 里，handler 是唯一一个&lt;strong&gt;被&lt;/strong&gt; &lt;strong&gt;​&lt;code&gt;net/http&lt;/code&gt;​&lt;/strong&gt;​ &lt;strong&gt;明确调用&lt;/strong&gt;的角色，它既是请求进入你业务代码的入口，也是生命周期在你这边结束的出口。&lt;/p&gt;
&lt;p&gt;一个最小、没有任何“设计感”的 handler 通常长这样：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;package main

import (
	&quot;fmt&quot;
	&quot;net/http&quot;
)

func main() {
	http.HandleFunc(&quot;/user&quot;, userHandler)
	fmt.Println(&quot;server start at :8080&quot;)
	http.ListenAndServe(&quot;:8080&quot;, nil)
}

func userHandler(w http.ResponseWriter, r *http.Request) {
	id := r.URL.Query().Get(&quot;id&quot;)
	w.Write([]byte(&quot;user id: &quot; + id))
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这段代码的问题并不是“简单”，而是&lt;strong&gt;什么都没限制&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;解析参数、执行业务、拼响应，全混在了一起。&lt;/p&gt;
&lt;p&gt;而 Go 并不会替你拆分这些责任，它只保证：这个函数会在请求生命周期内被调用一次。&lt;/p&gt;
&lt;p&gt;当代码逐渐变复杂，middleware 出现的动机往往不是“优雅”，而是​&lt;strong&gt;你开始意识到有些事情不属于具体业务&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;middleware 本质上只是一个高阶函数，它做的事情非常克制：&lt;strong&gt;在不改变 handler 语义的前提下，包裹请求生命周期的一部分。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;下面是一个最小、完整、可运行的 middleware 示例，用来打印请求耗时：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;package main

import (
	&quot;fmt&quot;
	&quot;net/http&quot;
	&quot;time&quot;
)

func main() {
	http.Handle(&quot;/hello&quot;, timingMiddleware(http.HandlerFunc(helloHandler)))
	fmt.Println(&quot;server start at :8080&quot;)
	http.ListenAndServe(&quot;:8080&quot;, nil)
}

func timingMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		next.ServeHTTP(w, r)
		fmt.Println(&quot;cost:&quot;, time.Since(start))
	})
}

func helloHandler(w http.ResponseWriter, r *http.Request) {
	time.Sleep(100 * time.Millisecond)
	w.Write([]byte(&quot;hello&quot;))
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;middleware 并不知道“业务在干嘛”，它也不应该知道。&lt;/p&gt;
&lt;p&gt;它只关心：&lt;strong&gt;请求开始了，什么时候结束，中间发生了什么通用行为&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;这也是为什么 middleware 非常适合做这些事情：日志、鉴权、限流、trace、注入 request-scoped 的数据。&lt;/p&gt;
&lt;p&gt;它们共同的特征是：&lt;strong&gt;它们横跨请求生命周期，但不拥有业务含义。&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;到了 service 这一层，视角会发生一个非常重要的变化。&lt;/p&gt;
&lt;p&gt;service 并不知道 HTTP，也不应该知道。&lt;/p&gt;
&lt;p&gt;它拿到的，应该只是一个 context，和一些已经被“解释过”的参数。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;package main

import (
	&quot;context&quot;
	&quot;fmt&quot;
	&quot;net/http&quot;
)

func main() {
	http.HandleFunc(&quot;/greet&quot;, greetHandler)
	fmt.Println(&quot;server start at :8080&quot;)
	http.ListenAndServe(&quot;:8080&quot;, nil)
}

func greetHandler(w http.ResponseWriter, r *http.Request) {
	name := r.URL.Query().Get(&quot;name&quot;)
	if name == &quot;&quot; {
		name = &quot;world&quot;
	}

	result := greetService(r.Context(), name)
	w.Write([]byte(result))
}

func greetService(ctx context.Context, name string) string {
	_ = ctx // 这里暂时不使用，只是明确服务运行在请求上下文中
	return &quot;hello &quot; + name
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在这里，handler 的职责变得非常清楚：&lt;strong&gt;把 HTTP 世界里的东西，翻译成业务世界能理解的形式。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;而 service 的职责也随之清晰起来：&lt;strong&gt;在一个明确的上下文里，完成一件业务上的事。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这条分界线其实非常“冷静”：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;handler 关心协议、参数、状态码、响应格式&lt;/li&gt;
&lt;li&gt;service 关心业务规则、数据一致性、流程完整性&lt;/li&gt;
&lt;li&gt;middleware 只关心请求这件事本身的通用横切面&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果你把 service 写成“还能访问 &lt;code&gt;http.Request&lt;/code&gt; 的函数”，那其实是在否认请求生命周期的边界；&lt;/p&gt;
&lt;p&gt;如果你把业务判断塞进 middleware，那是在把生命周期管理和业务语义搅在一起。&lt;/p&gt;
&lt;p&gt;慢慢看下来你会发现，Go 并没有强迫你采用这套分层，但它用非常原始的接口设计，把问题摆在你面前：&lt;strong&gt;HTTP 请求只会在 handler 这一层真实存在。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;所有其他层级，要么是在请求外（比如异步任务），要么只是借用了请求的生命周期（通过 context）。&lt;/p&gt;
&lt;p&gt;所以对我来说，handler / middleware / service 的边界，并不是“最佳实践”，而是一种对现实的尊重：&lt;strong&gt;请求有生命周期，而职责分离，只是我们对这个生命周期做出的最诚实的划分。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;60. request 级资源的创建与释放&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;当前问题存在示例代码，可以前往&lt;a href=&quot;https://github.com/zxc7563598/go-lab/commit/0fd5762d78f2fef05be7e82dce9689526546be2d&quot;&gt;GitHub查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;这一节其实是前面两节的自然延伸。&lt;/p&gt;
&lt;p&gt;当你真正接受了“HTTP 请求是有明确生命周期的”这件事之后，下一个绕不开的问题就是：&lt;strong&gt;有哪些东西，应该只活在这一次请求里。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;也就是所谓的 request 级资源。&lt;/p&gt;
&lt;p&gt;在 PHP 的世界里，请求级资源往往是“顺手就有的”：全局变量、超全局数组、请求结束时的自动回收，让你很少需要主动思考“释放”这件事。&lt;/p&gt;
&lt;p&gt;而在 Go 里，一旦你开始并发、开始复用进程，这个问题就变得不可回避。&lt;/p&gt;
&lt;p&gt;最典型的 request 级资源，其实就是 &lt;code&gt;context.Context&lt;/code&gt; 本身。&lt;/p&gt;
&lt;p&gt;它不是资源的载体，而是&lt;strong&gt;资源生命周期的信号源&lt;/strong&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;package main

import (
	&quot;context&quot;
	&quot;fmt&quot;
	&quot;net/http&quot;
	&quot;time&quot;
)

func main() {
	http.HandleFunc(&quot;/ctx&quot;, handler)
	fmt.Println(&quot;server start at :8080&quot;)
	http.ListenAndServe(&quot;:8080&quot;, nil)
}

func handler(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()

	result := doWork(ctx)
	w.Write([]byte(result))
}

func doWork(ctx context.Context) string {
	select {
	case &amp;#x3C;-time.After(2 * time.Second):
		return &quot;done&quot;
	case &amp;#x3C;-ctx.Done():
		return &quot;canceled&quot;
	}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里没有任何“释放代码”，但释放已经发生了：&lt;/p&gt;
&lt;p&gt;当请求结束，&lt;code&gt;ctx.Done()&lt;/code&gt; 被关闭，所有监听它的下游逻辑都会知道——这次请求不在了。&lt;/p&gt;
&lt;p&gt;在我刚开始学 Go Web 的时候，很容易把注意力放在“怎么创建资源”上，却忽略了一个更重要的问题：&lt;strong&gt;是谁负责告诉这些资源：你该结束了。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;数据库连接是一个非常容易踩到这个问题的地方。&lt;/p&gt;
&lt;p&gt;假设我们在 handler 里，为每个请求打开一个数据库连接：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func handler(w http.ResponseWriter, r *http.Request) {
	db, _ := sql.Open(&quot;mysql&quot;, &quot;dsn&quot;)
	defer db.Close()

	// 使用 db
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这段代码看起来“有释放”，但它在语义上其实并不对。&lt;/p&gt;
&lt;p&gt;​&lt;code&gt;sql.DB&lt;/code&gt; 在 Go 里是一个连接池，而不是一次连接。把它当成 request 级资源去创建和关闭，本身就是对生命周期的误判。&lt;/p&gt;
&lt;p&gt;这件事反过来说明了一个原则：&lt;strong&gt;是不是 request 级资源，不取决于“是不是在 handler 里创建的”，而取决于“是否应该随请求结束而失效”。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;真正典型的 request 级资源，往往是这些东西：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;请求级的超时、取消信号（context）&lt;/li&gt;
&lt;li&gt;一次业务流程中临时构建的对象&lt;/li&gt;
&lt;li&gt;绑定在请求上的 tracing / logging 信息&lt;/li&gt;
&lt;li&gt;必须在请求结束前完成或放弃的 I/O 操作&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;比如一个带超时的下游调用：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;package main

import (
	&quot;context&quot;
	&quot;fmt&quot;
	&quot;net/http&quot;
	&quot;time&quot;
)

func main() {
	http.HandleFunc(&quot;/timeout&quot;, handler)
	fmt.Println(&quot;server start at :8080&quot;)
	http.ListenAndServe(&quot;:8080&quot;, nil)
}

func handler(w http.ResponseWriter, r *http.Request) {
	ctx, cancel := context.WithTimeout(r.Context(), time.Second)
	defer cancel()

	result := callService(ctx)
	w.Write([]byte(result))
}

func callService(ctx context.Context) string {
	select {
	case &amp;#x3C;-time.After(2 * time.Second):
		return &quot;ok&quot;
	case &amp;#x3C;-ctx.Done():
		return &quot;timeout&quot;
	}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的 &lt;code&gt;cancel()&lt;/code&gt; 就是一次非常明确的释放行为。&lt;/p&gt;
&lt;p&gt;不是释放内存，而是&lt;strong&gt;释放继续占用时间和资源的资格&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;同样的思路也适用于文件、网络请求、stream 之类的资源。&lt;/p&gt;
&lt;p&gt;如果它们的存在意义只服务于当前请求，那释放时机就应该和请求生命周期绑定。&lt;/p&gt;
&lt;p&gt;一个常见的危险信号是：&lt;strong&gt;request 级资源被 goroutine 带出了 handler。&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func handler(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()

	go func() {
		// 这里还在使用 ctx
		doSomething(ctx)
	}()

	w.Write([]byte(&quot;ok&quot;))
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;从代码上看它是“合法的”，但从生命周期上看，它已经开始模糊边界了。&lt;/p&gt;
&lt;p&gt;如果这是一个必须完成的任务，它就不应该绑定在 request 上；&lt;/p&gt;
&lt;p&gt;如果它可以被放弃，那它就不应该假装自己还属于这次请求。&lt;/p&gt;
&lt;p&gt;慢慢你会发现，在 Go Web 里，所谓的资源管理，并不只是 &lt;code&gt;defer Close()&lt;/code&gt; 这么简单。&lt;/p&gt;
&lt;p&gt;它更像是在反复问自己一个问题：&lt;strong&gt;这件东西，有没有理由活得比一次 HTTP 请求更久？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;如果答案是否定的，那它就应该被显式地创建在请求内，并且明确地随着请求结束而失效；&lt;/p&gt;
&lt;p&gt;如果答案是肯定的，那它就不应该被包装成 request 级资源。&lt;/p&gt;
&lt;p&gt;对我来说，这一节并没有带来某个“技巧”，而是让我开始用生命周期来审视代码。&lt;/p&gt;
&lt;p&gt;一旦你习惯这样看问题，很多设计上的纠结，反而会自己消失。&lt;/p&gt;
&lt;h3&gt;61. Web 中的并发模型与 goroutine 数量控制&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;当前问题存在示例代码，可以前往&lt;a href=&quot;https://github.com/zxc7563598/go-lab/commit/0fd5762d78f2fef05be7e82dce9689526546be2d&quot;&gt;GitHub查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;这一节其实是整个「生命周期意识」里最容易被误解、也最容易被滥用的一部分。&lt;/p&gt;
&lt;p&gt;因为在 Go 里，&lt;strong&gt;并发写起来实在太轻松了&lt;/strong&gt;，轻松到你很容易忘记自己到底启动了多少 goroutine，它们又活在什么生命周期里。&lt;/p&gt;
&lt;p&gt;在 Web 场景下，理解并发模型的第一步，是承认一个看起来很“废话”的事实：&lt;strong&gt;HTTP 请求本身已经是并发的。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;​&lt;code&gt;net/http&lt;/code&gt; 在接收到请求时，就已经为每个请求分配了独立的 goroutine。也就是说，当你的 handler 被调用时，你已经站在并发执行的上下文中了。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;package main

import (
	&quot;fmt&quot;
	&quot;net/http&quot;
	&quot;time&quot;
)

func main() {
	http.HandleFunc(&quot;/work&quot;, handler)
	fmt.Println(&quot;server start at :8080&quot;)
	http.ListenAndServe(&quot;:8080&quot;, nil)
}

func handler(w http.ResponseWriter, r *http.Request) {
	time.Sleep(1 * time.Second)
	fmt.Println(&quot;request done&quot;)
	w.Write([]byte(&quot;ok&quot;))
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;同时访问 &lt;code&gt;/work&lt;/code&gt; 多次，你会发现这些请求是并行完成的。&lt;/p&gt;
&lt;p&gt;这意味着一个非常重要的前提：&lt;strong&gt;大多数 Web handler 根本不需要再主动开启 goroutine。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;刚从 PHP 转过来的时候，我很容易把 goroutine 当成“异步工具”，一看到耗时操作，就下意识地想 &lt;code&gt;go func()&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;但在 Web 请求里，这种直觉往往是错的。&lt;/p&gt;
&lt;p&gt;如果你在 handler 里写出这样的代码：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func handler(w http.ResponseWriter, r *http.Request) {
	go doSomething()
	w.Write([]byte(&quot;ok&quot;))
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;表面上看，这是“提升性能”；&lt;/p&gt;
&lt;p&gt;但从生命周期角度看，这段代码已经做了一个非常明确的切割：&lt;strong&gt;你主动让一部分逻辑脱离了 HTTP 请求。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;如果 &lt;code&gt;doSomething&lt;/code&gt; 和这次请求强相关，那你其实是在逃避请求的生命周期；&lt;/p&gt;
&lt;p&gt;如果它不再重要，那你就需要为它建立一个新的生命周期，而不是“顺手丢进 goroutine”。&lt;/p&gt;
&lt;p&gt;真正合理的并发，往往发生在&lt;strong&gt;请求内部，而不是请求之外&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;比如一次请求中，需要并行调用两个下游服务：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;package main

import (
	&quot;context&quot;
	&quot;fmt&quot;
	&quot;net/http&quot;
	&quot;sync&quot;
	&quot;time&quot;
)

func main() {
	http.HandleFunc(&quot;/multi&quot;, handler)
	fmt.Println(&quot;server start at :8080&quot;)
	http.ListenAndServe(&quot;:8080&quot;, nil)
}

func handler(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()

	var wg sync.WaitGroup
	wg.Add(2)

	var a, b string

	go func() {
		defer wg.Done()
		a = callA(ctx)
	}()

	go func() {
		defer wg.Done()
		b = callB(ctx)
	}()

	wg.Wait()
	w.Write([]byte(a + &quot; &amp;#x26; &quot; + b))
}

func callA(ctx context.Context) string {
	time.Sleep(500 * time.Millisecond)
	return &quot;A&quot;
}

func callB(ctx context.Context) string {
	time.Sleep(700 * time.Millisecond)
	return &quot;B&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的 goroutine 数量是&lt;strong&gt;被请求生命周期严格包裹住的&lt;/strong&gt;：handler 不返回，这些 goroutine 就必须结束；context 被取消，它们就应该尽快停止。&lt;/p&gt;
&lt;p&gt;这类并发是“可控的”，因为它有明确的边界。&lt;/p&gt;
&lt;p&gt;真正的问题，通常出现在 goroutine 数量的失控上。&lt;/p&gt;
&lt;p&gt;Web 服务的并发量，本身就等于&lt;strong&gt;同时活跃的请求数&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;如果你在每个请求里，再无条件启动多个 goroutine，那最终的 goroutine 数量会变成：请求数 × 每个请求的 goroutine 数&lt;/p&gt;
&lt;p&gt;这个乘法关系，往往是在压测或线上才暴露出来的。&lt;/p&gt;
&lt;p&gt;一个非常朴素但有效的控制方式，是​&lt;strong&gt;用显式的并发上限，来约束请求内的 goroutine&lt;/strong&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;package main

import (
	&quot;fmt&quot;
	&quot;net/http&quot;
	&quot;time&quot;
)

var sem = make(chan struct{}, 10)

func main() {
	http.HandleFunc(&quot;/limit&quot;, handler)
	fmt.Println(&quot;server start at :8080&quot;)
	http.ListenAndServe(&quot;:8080&quot;, nil)
}

func handler(w http.ResponseWriter, r *http.Request) {
	sem &amp;#x3C;- struct{}{}
	defer func() { &amp;#x3C;-sem }()

	time.Sleep(500 * time.Millisecond)
	w.Write([]byte(&quot;ok&quot;))
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里没有任何“高深技巧”，只是明确告诉系统：&lt;strong&gt;同一时间，最多允许 10 个请求进入这个逻辑。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;你会发现，Go 提供的并发原语并不帮你“自动做对”，它们只是让你​&lt;strong&gt;无法再忽视并发的存在&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;慢慢理解下来，我对 Go Web 并发模型的看法也发生了变化：goroutine 不是性能工具，而是&lt;strong&gt;生命周期工具&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;它让你可以非常精确地描述：哪些逻辑应该并行，哪些必须收敛，哪些不该再继续存在。&lt;/p&gt;
&lt;p&gt;一旦你开始用这种视角看代码，“goroutine 要不要开”“开几个”“什么时候结束”，这些问题反而会变得清楚很多。&lt;/p&gt;
&lt;p&gt;到这里，其实「Go Web 中的生命周期意识」这一章也就自然收束了。&lt;/p&gt;
&lt;p&gt;请求的开始、职责的边界、资源的生死、并发的收敛，讲的始终是同一件事：&lt;strong&gt;你是否真的尊重了一次 HTTP 请求的存在范围。&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;十四、工程化：写“可长期维护的 Go 服务”&lt;/h2&gt;
&lt;h3&gt;62. internal / pkg 的设计目的&lt;/h3&gt;
&lt;p&gt;第一次看到 Go 项目里同时出现 &lt;code&gt;internal&lt;/code&gt;​ 和 &lt;code&gt;pkg&lt;/code&gt; 的时候，我的直觉其实是困惑的。&lt;/p&gt;
&lt;p&gt;它们看起来都像是在“放业务代码”，也不像 MVC 那样有明确的角色划分，如果从 PHP 的习惯来看，这更像是人为增加目录层级。&lt;/p&gt;
&lt;p&gt;但慢慢看下来我意识到，&lt;code&gt;internal / pkg&lt;/code&gt;​ 并不是在解决“代码怎么组织得好看”，而是在解决一个更工程化的问题：​&lt;strong&gt;哪些代码允许被依赖，哪些代码不允许被依赖&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;在 PHP 里，这件事通常是靠“约定”和“自觉”完成的。&lt;/p&gt;
&lt;p&gt;比如你心里知道某个目录只是内部实现细节，但语言和工具层面并不会阻止别人 &lt;code&gt;use&lt;/code&gt; 它；最多是靠文档、命名、或者 review 时提醒。&lt;/p&gt;
&lt;p&gt;但 Go 把这件事直接变成了&lt;strong&gt;编译期规则&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;​&lt;code&gt;internal&lt;/code&gt;​ 的核心设计目的只有一个：&lt;strong&gt;限制包只能被特定范围内的代码导入&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;规则本身非常简单：如果一个包位于 &lt;code&gt;internal/xxx&lt;/code&gt;​ 下，那么只有 &lt;code&gt;internal&lt;/code&gt; 的父目录及其子目录，才能 import 它。&lt;/p&gt;
&lt;p&gt;这个限制不是约定，而是写进了 &lt;code&gt;go build&lt;/code&gt; 的规则里，违反就直接编译失败。&lt;/p&gt;
&lt;p&gt;比如一个最小的结构：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;myapp/
├── go.mod
├── internal/
│   └── auth/
│       └── auth.go
└── cmd/
    └── server/
        └── main.go
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;​&lt;code&gt;auth.go&lt;/code&gt; 内容很简单：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;package auth

func Check() string {
	return &quot;ok&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;​&lt;code&gt;cmd/server/main.go&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;package main

import (
	&quot;fmt&quot;
	&quot;myapp/internal/auth&quot;
)

func main() {
	fmt.Println(auth.Check())
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个是​&lt;strong&gt;可以正常编译运行的&lt;/strong&gt;​，因为 &lt;code&gt;cmd/server&lt;/code&gt;​ 仍然在 &lt;code&gt;myapp&lt;/code&gt; 这个模块路径之下，满足 internal 的可见性规则。&lt;/p&gt;
&lt;p&gt;但如果你在另一个模块里这么写：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;package main

import &quot;myapp/internal/auth&quot;

func main() {
	auth.Check()
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;编译器会直接报错，明确告诉你：你无权导入这个 internal 包。&lt;/p&gt;
&lt;p&gt;这一步对我冲击挺大的，因为它意味着： &lt;strong&gt;“这是内部实现”不再只是态度问题，而是边界问题。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;也就是说，&lt;code&gt;internal&lt;/code&gt; 并不是“私有代码”，而是“明确拒绝外部依赖的代码”。&lt;/p&gt;
&lt;p&gt;它在设计层面就在告诉未来的维护者：这里面的东西，随时可能改、可能删、可能重构，不对外承担稳定性责任。&lt;/p&gt;
&lt;p&gt;理解了这一点之后，再看 &lt;code&gt;pkg&lt;/code&gt;，反而清楚多了。&lt;/p&gt;
&lt;p&gt;​&lt;code&gt;pkg&lt;/code&gt; 并不是一个语法或编译层面的概念，它完全是社区约定。&lt;/p&gt;
&lt;p&gt;但这个约定背后的意图和 &lt;code&gt;internal&lt;/code&gt;​ 是对称的：&lt;strong&gt;如果你把代码放进&lt;/strong&gt; &lt;strong&gt;​&lt;code&gt;pkg&lt;/code&gt;​&lt;/strong&gt;​ &lt;strong&gt;，那基本等于在说：这是一组打算被别人依赖的包。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;也就是说：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;​&lt;code&gt;internal&lt;/code&gt;：我明确不希望你用&lt;/li&gt;
&lt;li&gt;​&lt;code&gt;pkg&lt;/code&gt;：我已经默认你可能会用&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这并不意味着 &lt;code&gt;pkg&lt;/code&gt;​ 里的代码一定“完美”或“稳定”，而是它在工程语义上​&lt;strong&gt;承担了依赖入口的角色&lt;/strong&gt;。你在这里改一个函数签名，心理预期就应该是：可能会影响模块外的调用者。&lt;/p&gt;
&lt;p&gt;从这个角度看，&lt;code&gt;internal / pkg&lt;/code&gt;​ 更像是一种​&lt;strong&gt;依赖方向的标注系统&lt;/strong&gt;，而不是目录分类技巧。&lt;/p&gt;
&lt;p&gt;还有一个我后来才意识到的点：&lt;code&gt;internal&lt;/code&gt;​ 实际上是在&lt;strong&gt;强迫你把“实现细节”聚集起来&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;因为一旦你开始用 internal，你就会自然地去问自己一句话：这个包，是真的需要被模块外使用吗？&lt;/p&gt;
&lt;p&gt;很多在 PHP 项目里“顺手就 public 了”的东西，在 Go 里一旦放进 internal，就会变成一个清晰的边界。这种边界不是给编译器看的，而是给&lt;strong&gt;未来的自己和团队&lt;/strong&gt;看的。&lt;/p&gt;
&lt;h3&gt;63. 依赖方向与反向依赖的处理&lt;/h3&gt;
&lt;p&gt;在理解了 &lt;code&gt;internal / pkg&lt;/code&gt;​ 之后，我才意识到一个之前被我忽略的问题：&lt;strong&gt;边界一旦存在，依赖方向就不再是随意的了。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;在很多 PHP 项目里，依赖关系往往是“哪里用得到就直接引”。&lt;/p&gt;
&lt;p&gt;业务代码依赖基础组件是常态，基础组件偶尔为了“方便”，也会反过来引用一点业务逻辑，只要不出事，项目依然能跑。这种结构在短期内并不会暴露明显问题。&lt;/p&gt;
&lt;p&gt;但在 Go 的工程化语境下，这种“互相方便一下”的做法，很快就会让你感觉到不对劲。&lt;/p&gt;
&lt;p&gt;Go 项目里，一个默认且非常强的约束是：&lt;strong&gt;依赖应该是单向的，而且方向是稳定的。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这个“稳定”并不是说永远不变，而是说：你应该能清楚地说出，哪些层是“被依赖者”，哪些层是“依赖者”。&lt;/p&gt;
&lt;p&gt;一个常见的直觉是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;越底层、越通用的代码，越应该少依赖别人&lt;/li&gt;
&lt;li&gt;越靠近业务入口的代码，越应该承担“组装”的职责&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果画成一条方向线，那大致是：&lt;code&gt;main / cmd → 业务层 → 领域或服务层 → 基础设施&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;问题恰恰出在这里：&lt;strong&gt;现实项目里，总会出现“反着用更顺手”的时刻。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;比如，一个通用的组件在内部，突然需要知道“当前用户是谁”；&lt;/p&gt;
&lt;p&gt;或者一个基础库，想直接打业务日志、发业务事件；&lt;/p&gt;
&lt;p&gt;再或者，一个 domain 包想直接操作数据库连接池。&lt;/p&gt;
&lt;p&gt;从写代码的角度看，这些都很自然。&lt;/p&gt;
&lt;p&gt;但从依赖方向看，它们在做同一件事：&lt;strong&gt;反向依赖&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;在 Go 里，反向依赖几乎一定会把你逼进死角。&lt;/p&gt;
&lt;p&gt;最直观的表现就是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;import 循环&lt;/li&gt;
&lt;li&gt;internal 边界被打破&lt;/li&gt;
&lt;li&gt;或者为了“解决问题”，开始把东西随便往上挪&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;而 Go 对 import 循环是零容忍的，一行情面都不讲。&lt;/p&gt;
&lt;p&gt;这时候我才慢慢理解，Go 并不是在“限制你写代码”，而是在​&lt;strong&gt;强迫你正视依赖关系本身&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;那反向依赖怎么处理？&lt;/p&gt;
&lt;p&gt;Go 社区里最常见、也最朴素的解法，其实只有一个核心思想：&lt;strong&gt;不要让下游知道上游的具体实现，而是只知道抽象。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;而这个“抽象”，在 Go 里往往不是类，也不是框架，而是一个非常轻量的接口。&lt;/p&gt;
&lt;p&gt;举一个极小的例子。&lt;/p&gt;
&lt;p&gt;假设有一个内部包，需要记录日志，但它不应该依赖具体的日志实现：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;// internal/service/service.go
package service

type Logger interface {
	Info(msg string)
}

type Service struct {
	logger Logger
}

func New(l Logger) *Service {
	return &amp;#x26;Service{logger: l}
}

func (s *Service) Do() {
	s.logger.Info(&quot;doing something&quot;)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个 &lt;code&gt;service&lt;/code&gt;​ 包只依赖一个“行为约定”，而不是 &lt;code&gt;zap&lt;/code&gt;​、&lt;code&gt;logrus&lt;/code&gt; 或任何具体库。&lt;/p&gt;
&lt;p&gt;真正的日志实现，放在更外层去做：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;// cmd/server/main.go
package main

import (
	&quot;myapp/internal/service&quot;
)

type StdLogger struct{}

func (StdLogger) Info(msg string) {
	println(msg)
}

func main() {
	svc := service.New(StdLogger{})
	svc.Do()
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里依赖方向是清晰的：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;​&lt;code&gt;service&lt;/code&gt; 不知道“谁在用它”&lt;/li&gt;
&lt;li&gt;​&lt;code&gt;main&lt;/code&gt; 负责把具体实现“注入”进去&lt;/li&gt;
&lt;li&gt;依赖永远是从外向内流动的&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这件事在 PHP 里也能做，但往往是靠约定、靠文档、靠经验；&lt;/p&gt;
&lt;p&gt;而在 Go 里，它几乎是被 import 规则和工程实践“逼”出来的。&lt;/p&gt;
&lt;p&gt;更有意思的是，当你真的开始这样拆依赖时，会发现一个变化：&lt;strong&gt;很多你以为“必须反向依赖”的需求，其实只是因为边界没想清楚。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;一旦你承认“某些代码只能被依赖、不能主动依赖”，你就会自然开始思考：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;这个信息，是不是应该由调用者提供？&lt;/li&gt;
&lt;li&gt;这个行为，是不是应该由外层来决定？&lt;/li&gt;
&lt;li&gt;这个包，是不是其实站在了错误的层级？&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;到这里，我对“依赖方向”的理解，已经不再是架构图里的箭头，而更像是一种持续存在的工程意识：&lt;strong&gt;每写一个 import，我都应该知道它意味着什么。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;而 Go 做的事情，只是把“想不清楚就先写”的空间，压缩得非常小。&lt;/p&gt;
&lt;h3&gt;64. 配置管理（viper）与环境区分&lt;/h3&gt;
&lt;p&gt;在开始用 Go 写服务之前，我对“配置”的理解其实非常工具化：要么是 &lt;code&gt;.env&lt;/code&gt;​，要么是几个 &lt;code&gt;config.php&lt;/code&gt;，不同环境加载不同文件，能跑就行。配置更多像是一种“启动参数的集合”，而不是工程结构的一部分。&lt;/p&gt;
&lt;p&gt;但在 Go 的工程语境里，当你已经刻意维护了依赖方向之后，配置会变成一个绕不开的问题：&lt;strong&gt;谁有资格读取配置？谁不应该知道配置来自哪里？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;如果你已经接受了“内部包不应该反向依赖外部实现”，那一个很自然的结论是：&lt;code&gt;internal&lt;/code&gt;​ 里的业务代码，&lt;strong&gt;不应该自己去读环境变量、配置文件或远程配置中心&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;原因并不是“这样写不好看”，而是：一旦这么做，它就偷偷引入了一个新的外部依赖，而且这个依赖几乎是不可控的。&lt;/p&gt;
&lt;p&gt;比如下面这种代码，在 PHP 里非常常见，在 Go 里却会让人越来越不安：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func NewService() *Service {
	timeout := os.Getenv(&quot;TIMEOUT&quot;)
	// ...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;表面看没什么问题，但实际上发生了几件事：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;这个包开始依赖“当前运行环境”&lt;/li&gt;
&lt;li&gt;它变得难以测试&lt;/li&gt;
&lt;li&gt;它无法复用在不同启动方式下&lt;/li&gt;
&lt;li&gt;你已经说不清它到底需要哪些配置&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;当你真的开始把“依赖方向”当成一条硬规则来对待时，配置就会被自然地推到​&lt;strong&gt;最外层&lt;/strong&gt;，也就是启动入口附近。&lt;/p&gt;
&lt;p&gt;这正是 viper 出现的位置。&lt;/p&gt;
&lt;p&gt;viper 本身并不神奇，它做的事情非常朴素：&lt;strong&gt;把“配置从哪里来”这件事统一收敛起来&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;文件、环境变量、命令行参数、默认值——viper 负责把它们揉成一份“最终配置”，而不是让这些细节散落在各个业务包里。&lt;/p&gt;
&lt;p&gt;一个最小的例子可能是这样：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;// cmd/server/main.go
package main

import (
	&quot;fmt&quot;

	&quot;github.com/spf13/viper&quot;
)

type Config struct {
	Port int
	Env  string
}

func loadConfig() (*Config, error) {
	viper.SetDefault(&quot;port&quot;, 8080)
	viper.SetDefault(&quot;env&quot;, &quot;dev&quot;)

	viper.AutomaticEnv()

	return &amp;#x26;Config{
		Port: viper.GetInt(&quot;port&quot;),
		Env:  viper.GetString(&quot;env&quot;),
	}, nil
}

func main() {
	cfg, _ := loadConfig()
	fmt.Println(cfg.Port, cfg.Env)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;关键点不在于 viper 的 API，而在于​&lt;strong&gt;配置只在这里被读取了一次&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;接下来，无论是 service、repository 还是 domain 层，都只会接触到一个普通的 &lt;code&gt;Config&lt;/code&gt; 结构体，而不会知道它来自文件还是环境变量。&lt;/p&gt;
&lt;p&gt;这件事和前一节讲的“反向依赖处理”其实是同一件事的延伸：&lt;strong&gt;你不是在避免配置，而是在避免对配置来源的依赖。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;环境区分的问题，也是在这个视角下变得清晰的。&lt;/p&gt;
&lt;p&gt;在很多项目里，“开发 / 测试 / 生产”往往意味着三套配置文件，甚至三套逻辑分支。但当配置被集中之后，环境更多只是一种“输入条件”，而不是结构差异。&lt;/p&gt;
&lt;p&gt;一个常见做法是，只用一个明确的 &lt;code&gt;env&lt;/code&gt; 字段：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;type Config struct {
	Env      string
	LogLevel string
	Debug    bool
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后在入口处做最小的判断：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;if cfg.Env == &quot;prod&quot; {
	// 开启严格模式
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;而不是让业务代码去判断“现在是不是生产环境”。&lt;/p&gt;
&lt;p&gt;这时候你会发现，环境区分不再是“到处 if else”，而是​&lt;strong&gt;启动阶段的一次决策&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;还有一个变化是：当配置结构是显式的，你就开始真正知道“这个服务到底需要什么才能跑起来”。&lt;/p&gt;
&lt;p&gt;在 PHP 项目里，很多配置是“慢慢长出来的”；&lt;/p&gt;
&lt;p&gt;但在 Go 里，当你把配置集中成一个结构体，它就天然变成了一份&lt;strong&gt;服务的运行契约&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;65. 结构化日志（zap）的使用原则&lt;/h3&gt;
&lt;p&gt;zap 是 Go 里常用的结构化日志库，它把日志从字符串输出，变成由字段组成的数据，并在性能和工程化使用上做了明确取舍。&lt;/p&gt;
&lt;p&gt;至于 API 或性能细节，其实并不是这一节真正关心的重点。&lt;/p&gt;
&lt;p&gt;真正让我开始认真思考日志问题的，是当我不再用 &lt;code&gt;fmt.Println&lt;/code&gt;​，而是开始使用结构化日志之后，日志本身就不再只是调试输出，而变成了一项需要被&lt;strong&gt;正确放置&lt;/strong&gt;的工程能力。&lt;/p&gt;
&lt;p&gt;如果回到前面已经形成的工程共识：依赖方向是稳定的，内部包不感知外部环境，启动入口负责组装一切外部能力，那么日志就不应该是一种“到处可用的全局工具”，而更像是一种被注入的、带上下文的依赖。&lt;/p&gt;
&lt;p&gt;这也是我逐渐意识到的一个使用原则：&lt;strong&gt;zap 的 logger 本身就是一种依赖&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;既然是依赖，它就应该遵守依赖方向的规则。&lt;/p&gt;
&lt;p&gt;在入口处创建 logger，然后向内层传递，是一种非常常见的做法：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;// cmd/server/main.go
logger, _ := zap.NewProduction()
defer logger.Sync()

svc := service.New(logger)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;而内部包只接收它真正需要的能力：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;// internal/service/service.go
type Service struct {
	logger *zap.Logger
}

func New(l *zap.Logger) *Service {
	return &amp;#x26;Service{logger: l}
}

func (s *Service) Do() {
	s.logger.Info(&quot;doing something&quot;)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里真正重要的，并不是“用了 zap”，而是：日志能力是从外部注入的，而不是在内部被隐式获取的。&lt;/p&gt;
&lt;p&gt;这一点，和前面关于配置、依赖方向的讨论，其实是同一件事的延续。&lt;/p&gt;
&lt;p&gt;当日志开始以这种方式存在时，我发现自己会不自觉地开始收敛日志的位置。&lt;/p&gt;
&lt;p&gt;一个很明显的变化是：&lt;strong&gt;越靠近业务核心的代码，日志越应该克制&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;业务代码关心的是“发生了什么”，而不是“如何被观测”。&lt;/p&gt;
&lt;p&gt;如果一个 domain 或 service 层的方法里充满了日志调用，那它其实已经在替外部系统做观测决策了，而这些决策本来就应该留在更外层。&lt;/p&gt;
&lt;p&gt;结构化日志在这里起到的作用，不是让日志“更复杂”，而是逼你区分两件事：事件本身，和事件的属性。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;s.logger.Info(
	&quot;user login failed&quot;,
	zap.String(&quot;user_id&quot;, uid),
	zap.String(&quot;reason&quot;, err.Error()),
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;相比拼接字符串，这种写法更像是在描述一个事件，而不是解释一段过程。一旦习惯这种方式，就会自然减少情绪化、解释性的日志，转而记录更偏事实的字段信息。&lt;/p&gt;
&lt;p&gt;还有一个后来才逐渐清晰的原则是：​&lt;strong&gt;不要在低层决定日志级别的语义&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;比如，一个 repository 方法返回了 &lt;code&gt;sql.ErrNoRows&lt;/code&gt;，它本身并不知道这是一个正常的业务分支，还是一个真正的异常情况。&lt;/p&gt;
&lt;p&gt;如果它在内部直接记录 &lt;code&gt;Warn&lt;/code&gt;​ 或 &lt;code&gt;Error&lt;/code&gt;，那实际上已经替上层做了判断。&lt;/p&gt;
&lt;p&gt;更合理的方式是：低层只负责返回结果或错误，而由真正理解业务语义的上层，来决定是否记录日志、记录到什么级别。&lt;/p&gt;
&lt;p&gt;到这里，我对 zap 的理解已经不再是“一个高性能日志库”，而更像是：它在不断提醒你，日志是一种语义表达，而不是调试输出。你不是在“打日志”，而是在为系统留下可被理解的行为痕迹。&lt;/p&gt;
&lt;h3&gt;66. 错误、日志、trace 的协作关系&lt;/h3&gt;
&lt;p&gt;在开始同时接触错误处理、结构化日志和 trace 之后，我有过一段明显的混乱期。&lt;/p&gt;
&lt;p&gt;同样是一次失败，有时候返回 error，有时候打日志，有时候加 trace，看起来都“说得通”，但放在一起就开始显得重复，甚至互相打架。&lt;/p&gt;
&lt;p&gt;后来我才慢慢意识到，这三者之所以容易被混用，往往是因为没有先想清楚一个问题：&lt;strong&gt;它们各自是为谁服务的。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;error 是最内层、也是最克制的一种表达。&lt;/p&gt;
&lt;p&gt;它的唯一职责，是把“发生了异常”这个事实，沿着调用链向上返回。&lt;/p&gt;
&lt;p&gt;一个 error 本身不负责被人“看到”，它只负责被处理。&lt;/p&gt;
&lt;p&gt;这也是为什么在 Go 里，error 被设计成普通返回值，而不是异常机制。&lt;/p&gt;
&lt;p&gt;它天然地顺着依赖方向向外传播，而不是在某个地方突然被“抛出来”。&lt;/p&gt;
&lt;p&gt;日志则完全不同。&lt;/p&gt;
&lt;p&gt;日志并不是为了控制流程，而是为了&lt;strong&gt;事后观察系统行为&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;也正因为如此，它不应该无条件地和 error 绑定在一起。&lt;/p&gt;
&lt;p&gt;一个很典型的误用，是在产生 error 的地方立刻记录日志。&lt;/p&gt;
&lt;p&gt;这样写当然没有语法问题，但它隐含了一个假设：这个 error 一定值得被记录，而且我已经知道该以什么语义记录。&lt;/p&gt;
&lt;p&gt;但在实际工程里，低层代码往往并不具备这样的判断能力。&lt;/p&gt;
&lt;p&gt;一个 error 是正常分支、边界条件，还是系统异常，通常只有更上层才真正清楚。&lt;/p&gt;
&lt;p&gt;所以在我现在的理解里，更合理的协作方式是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;error 负责表达问题&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;日志负责表达语义&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;低层返回 error，高层在“理解上下文”的地方决定是否记录日志，以及记录到什么级别。&lt;/p&gt;
&lt;p&gt;这一点，和前一节里“不要在低层决定日志级别”的结论是完全一致的。&lt;/p&gt;
&lt;p&gt;trace 则站在另一个维度。&lt;/p&gt;
&lt;p&gt;如果说 error 是“点”，日志是“事件”，那 trace 更像是一条&lt;strong&gt;跨越多个组件的时间线&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;它并不关心你在某一行代码里做了什么，而关心一次请求从进入系统开始，到离开系统为止，经历了哪些节点。&lt;/p&gt;
&lt;p&gt;也正因为这个定位，trace 通常既不由业务代码主动创建，也不由业务代码主动结束。&lt;/p&gt;
&lt;p&gt;它更像是一种运行时上下文，被创建于入口，被传递，被使用，而不是被“控制”。&lt;/p&gt;
&lt;p&gt;这也是为什么在 Go 里，trace 往往和 &lt;code&gt;context.Context&lt;/code&gt; 一起出现。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func Handle(ctx context.Context) error {
	// ctx 内部可能已经携带 trace 信息
	return doSomething(ctx)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的 &lt;code&gt;ctx&lt;/code&gt;​ 并不是用来存业务数据的，而是用来&lt;strong&gt;携带这次调用的“观察信息”&lt;/strong&gt; 。&lt;/p&gt;
&lt;p&gt;业务代码并不需要知道 trace 的具体实现，只需要把上下文继续往下传。&lt;/p&gt;
&lt;p&gt;把这三者放在一起看，会发现一个很清晰的层次关系：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;error：控制流程，向上返回&lt;/li&gt;
&lt;li&gt;log：表达语义，在合适的层记录&lt;/li&gt;
&lt;li&gt;trace：串联全局，跨边界观测&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;它们不是并列关系，也不是互相替代的工具，而是​&lt;strong&gt;各自负责一个不同的问题维度&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;当我开始按这个顺序来使用它们之后，一个变化非常明显：代码里的“重复表达”开始减少了。&lt;/p&gt;
&lt;p&gt;我不再需要在每个 error 产生的地方都打日志；&lt;/p&gt;
&lt;p&gt;也不需要用日志去模拟一次请求的完整生命周期；&lt;/p&gt;
&lt;p&gt;更不会用 error 去承载“调试信息”。&lt;/p&gt;
&lt;p&gt;到这里，这一整章“可长期维护的 Go 服务”，对我来说就不再是某几条零散的最佳实践，而是一组可以互相支撑的工程约束：边界清晰，依赖单向，外部因素集中处理，而观测能力各司其职。&lt;/p&gt;
&lt;p&gt;这些东西单独看都不复杂，但一旦连在一起，就会不断逼你把系统“想清楚之后再写出来”。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;十五、从 PHP 项目到 Go 项目的思维迁移&lt;/h2&gt;
&lt;h3&gt;67. 哪些 PHP 设计可以直接迁移&lt;/h3&gt;
&lt;p&gt;从 PHP 到 Go，并不是把一套设计推翻重来，而是发现：真正能迁移的，从来就不是语言技巧，而是你已经形成的工程判断。&lt;/p&gt;
&lt;p&gt;比如​&lt;strong&gt;分层意识&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;在 PHP 项目里，即便没有严格的 DDD，也很少有人真的把「控制器里直接写 SQL」当成理想状态。&lt;/p&gt;
&lt;p&gt;控制器负责接 HTTP、做参数校验；&lt;/p&gt;
&lt;p&gt;服务层负责业务组合；&lt;/p&gt;
&lt;p&gt;仓储或模型层负责数据访问。&lt;/p&gt;
&lt;p&gt;这一点在 Go 里不仅没有被否定，反而更“显性”了：你不再靠框架约定去暗示这些层次，而是靠 package、文件结构、接口边界去把它们写出来。&lt;/p&gt;
&lt;p&gt;本质上，思路是完全一致的，只是 Go 不帮你兜底。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;还有一类是 &lt;strong&gt;“把变化点隔离出来”&lt;/strong&gt; 的习惯。&lt;/p&gt;
&lt;p&gt;在 PHP 中，你可能早就习惯：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;外部接口要包一层&lt;/li&gt;
&lt;li&gt;第三方 SDK 不要散落在业务代码里&lt;/li&gt;
&lt;li&gt;配置集中管理，而不是到处 &lt;code&gt;getenv&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这些并不是 Laravel 或 Symfony 教你的，而是项目写久了自然形成的防御性设计。&lt;/p&gt;
&lt;p&gt;到了 Go，这套东西依然成立，而且更容易“被迫坚持”：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;因为没有魔术方法，乱调用会立刻变得难看&lt;/li&gt;
&lt;li&gt;因为类型是显式的，变化会第一时间传染到调用方&lt;/li&gt;
&lt;li&gt;因为编译期就能发现问题，你会更愿意提前把边界画清楚&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;你会发现：&lt;strong&gt;你不是在学新的设计，而是在被迫把以前模糊的设计说清楚。&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;再比如 &lt;strong&gt;“数据结构先于流程”&lt;/strong&gt; 这个习惯。&lt;/p&gt;
&lt;p&gt;在 PHP 中，很多时候我们是先把流程写出来，再用数组去“凑”数据结构。&lt;/p&gt;
&lt;p&gt;但稍微复杂一点的系统，很快就会演变成：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;约定好的数组 key&lt;/li&gt;
&lt;li&gt;注释说明这个数组“长什么样”&lt;/li&gt;
&lt;li&gt;靠经验保证大家用法一致&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果你已经写到这个阶段，那么迁移到 Go 时，其实几乎是无缝的：你只是把那些“靠注释和默契维持的结构”，变成了 struct。&lt;/p&gt;
&lt;p&gt;Go 并没有改变你的设计方式，只是把“隐含的前提”变成了“编译器要求你说明的事实”。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;还有一个很容易被忽略，但其实&lt;strong&gt;完全可迁移&lt;/strong&gt;的点：&lt;strong&gt;对副作用的警惕&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;成熟一点的 PHP 项目里，大家都会尽量避免：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;方法悄悄改全局状态&lt;/li&gt;
&lt;li&gt;隐式依赖某个单例&lt;/li&gt;
&lt;li&gt;调用一个函数却不知道它会不会顺带做别的事&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这些在 PHP 中是“自觉”，在 Go 中则几乎变成了“基本生存技能”。&lt;/p&gt;
&lt;p&gt;你会更频繁地思考：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;这个函数有没有状态&lt;/li&gt;
&lt;li&gt;这个状态归谁管&lt;/li&gt;
&lt;li&gt;这个改动会不会被并发放大&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;但这并不是 Go 才有的意识，而是你在 PHP 项目里已经形成的工程直觉，只是现在被放到了台面上。&lt;/p&gt;
&lt;h3&gt;68. 哪些 PHP 写法在 Go 中是反模式&lt;/h3&gt;
&lt;p&gt;很多 PHP 写法之所以“看起来没问题”，并不是因为它们本身多优雅，而是因为 &lt;strong&gt;PHP 的运行模型一直在替你兜底&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;当你把这些写法原样带进 Go 时，问题不是“能不能跑”，而是“跑起来之后你还能不能控制住它”。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;一个很典型的反模式是：​&lt;strong&gt;过度依赖隐式共享状态&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;在 PHP 里，我们很习惯：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;全局配置随时可读&lt;/li&gt;
&lt;li&gt;单例对象到处可用&lt;/li&gt;
&lt;li&gt;容器里拿服务几乎没有成本&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;因为一次请求结束后，所有状态都会被回收，“这次改了点什么”这件事，天然是短命的。&lt;/p&gt;
&lt;p&gt;但在 Go 里，进程是长期存在的，goroutine 是并发执行的。&lt;/p&gt;
&lt;p&gt;当你还用“反正下一个请求就重来”的心态去写代码时，&lt;/p&gt;
&lt;p&gt;共享状态就不再是便利，而会变成一个&lt;strong&gt;放大器&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;一个小小的可变字段&lt;/li&gt;
&lt;li&gt;在并发下被反复读写&lt;/li&gt;
&lt;li&gt;问题不会立刻出现，却很难复现&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这时候你会意识到：&lt;strong&gt;在 PHP 里被生命周期掩盖的问题，在 Go 里会被无限放大。&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;另一个很常见的，是​&lt;strong&gt;用“宽松数据结构”偷懒&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;PHP 里用数组承载一切信息，是一种被长期纵容的写法：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;同一个数组，在不同阶段拥有不同含义&lt;/li&gt;
&lt;li&gt;key 靠约定，而不是约束&lt;/li&gt;
&lt;li&gt;出错时，往往只是多了一个 &lt;code&gt;null&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这种写法在 PHP 中不算致命，因为运行时本身就非常宽容。&lt;/p&gt;
&lt;p&gt;但在 Go 里，如果你还试图用 &lt;code&gt;map[string]interface{}&lt;/code&gt; 去复刻这套灵活性，&lt;/p&gt;
&lt;p&gt;你会发现：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;类型断言开始到处出现&lt;/li&gt;
&lt;li&gt;调用方需要知道更多内部细节&lt;/li&gt;
&lt;li&gt;错误变得延迟、分散、不可预测&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这并不是 Go “不够灵活”，而是它&lt;strong&gt;拒绝替你兜住不确定性&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;你一旦继续依赖这种宽松结构，就等于主动放弃了 Go 能提供的安全感。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;还有一种反模式，来自 &lt;strong&gt;“把流程当作一切”&lt;/strong&gt; 的思维。&lt;/p&gt;
&lt;p&gt;很多 PHP 代码，本质上是“脚本式”的：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;从上到下&lt;/li&gt;
&lt;li&gt;边查数据边处理&lt;/li&gt;
&lt;li&gt;顺手在中间修改点状态&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;在 Web 请求模型下，这种写法非常自然，而且往往足够快、足够清晰。&lt;/p&gt;
&lt;p&gt;但当你在 Go 中开始写并发、写异步、写长期运行的任务时，这种“流程即设计”的代码会迅速变得脆弱：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;状态散落在流程的各个角落&lt;/li&gt;
&lt;li&gt;很难拆分、复用或并发执行&lt;/li&gt;
&lt;li&gt;一旦中途失败，回滚和补偿几乎无从谈起&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;你会慢慢发现，在 Go 里，&lt;strong&gt;流程只是结果，结构和边界才是设计本身。&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;还有一个经常被忽略的点：​&lt;strong&gt;用“约定”代替约束&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;在 PHP 项目中，我们很习惯靠：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;文档说明参数怎么传&lt;/li&gt;
&lt;li&gt;注释约定返回值含义&lt;/li&gt;
&lt;li&gt;Code Review 来保证大家“别乱用”&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这在 PHP 里是现实选择，因为语言本身给不了你更多工具。&lt;/p&gt;
&lt;p&gt;但在 Go 里，如果你仍然选择只靠约定，而不通过：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;明确的类型&lt;/li&gt;
&lt;li&gt;明确的接口&lt;/li&gt;
&lt;li&gt;明确的错误返回&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;那你其实是在​&lt;strong&gt;刻意绕开 Go 的设计初衷&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;不是 Go 强迫你这么写，而是你一旦不这么写，就失去了它存在的意义。&lt;/p&gt;
&lt;h3&gt;69. Go 中“复制数据”往往比“共享数据”更安全&lt;/h3&gt;
&lt;p&gt;在 Go 里，​&lt;strong&gt;共享数据的危险并不是“会出错”，而是“你很难证明它没出错”&lt;/strong&gt; 。&lt;/p&gt;
&lt;p&gt;当一个 struct、一个 map、一个 slice 被多个 goroutine 持有时，哪怕你“逻辑上觉得没问题”，你也需要开始思考一连串以前不需要思考的事情：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;谁负责写？&lt;/li&gt;
&lt;li&gt;什么时候写？&lt;/li&gt;
&lt;li&gt;读的时候是否可能被改？&lt;/li&gt;
&lt;li&gt;这个假设未来还成立吗？&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;问题在于，这些假设本身&lt;strong&gt;不会被代码显式表达出来&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;它们存在于你的脑子里，而不是存在于程序里。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;于是你会慢慢发现一种非常反直觉的现象：&lt;strong&gt;多拷贝几份数据，代码反而更简单、更安全。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;在 PHP 里，复制往往意味着性能浪费，而共享看起来是理所当然的。&lt;/p&gt;
&lt;p&gt;但在 Go 里，很多时候你复制的并不是“巨大成本”，而是在用内存换取一种非常确定的语义：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;这份数据只属于当前 goroutine&lt;/li&gt;
&lt;li&gt;它的生命周期清晰&lt;/li&gt;
&lt;li&gt;不会被别人悄悄改掉&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;一旦你接受了这个前提，很多并发问题会&lt;strong&gt;自动消失&lt;/strong&gt;，而不是靠锁去“压住”。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;你可能会注意到，Go 的很多设计，都在​&lt;strong&gt;暗示你偏向复制而不是共享&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;函数参数默认就是值传递&lt;/li&gt;
&lt;li&gt;struct 是可以直接拷贝的&lt;/li&gt;
&lt;li&gt;channel 更鼓励你传“数据快照”，而不是传指针&lt;/li&gt;
&lt;li&gt;官方示例里，锁往往是最后的选择，而不是默认选项&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这些并不是偶然的 API 设计，而是在持续地引导你：&lt;strong&gt;把数据的所有权想清楚，比把锁写对更重要。&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;对 PHP 开发者来说，这其实是一种​&lt;strong&gt;所有权意识的觉醒&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;在 PHP 中，变量的“归属感”是很弱的：反正请求结束就没了，反正下一个请求是全新的世界。&lt;/p&gt;
&lt;p&gt;而在 Go 里，你会被迫回答这样的问题：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;这份数据现在属于谁？&lt;/li&gt;
&lt;li&gt;它会被传给谁？&lt;/li&gt;
&lt;li&gt;我是否还需要关心它之后的变化？&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;当你无法清晰回答这些问题时，复制，反而成了一种非常诚实的做法：&lt;strong&gt;我不打算管理共享，那就干脆不共享。&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;这也是为什么你会慢慢形成一种偏好：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;小数据，直接复制&lt;/li&gt;
&lt;li&gt;明确边界，传值&lt;/li&gt;
&lt;li&gt;只有在“确实需要共享状态”时，才引入锁或同步原语&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这不是性能至上的选择，而是一种&lt;strong&gt;把复杂度控制在可理解范围内的选择&lt;/strong&gt;。&lt;/p&gt;
&lt;h3&gt;70. 典型 PHP 模块的 Go 化重构思路&lt;/h3&gt;
&lt;p&gt;如果拿一个典型的 PHP 模块来看，通常会长得很“自然”：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;一个 Service 类&lt;/li&gt;
&lt;li&gt;里面挂着好几个依赖（DB、缓存、HTTP 客户端）&lt;/li&gt;
&lt;li&gt;方法里既有业务判断，也顺手做了数据访问和状态修改&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;在 PHP 的请求模型下，这种模块往往没有明显问题，因为它的职责边界是由“请求生命周期”隐式保证的。&lt;/p&gt;
&lt;p&gt;但当你把它搬到 Go 中时，第一个遇到的不是语法问题，而是一个更根本的问题：&lt;strong&gt;这个模块到底是“状态的拥有者”，还是“逻辑的执行者”？&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;一个常见的 Go 化重构起点，是​&lt;strong&gt;先拆掉“隐式上下文”&lt;/strong&gt; 。&lt;/p&gt;
&lt;p&gt;在 PHP 中，Service 往往默认可以：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;直接用全局配置&lt;/li&gt;
&lt;li&gt;直接访问容器里的其他服务&lt;/li&gt;
&lt;li&gt;直接假设某些前置状态已经存在&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;而在 Go 中，这些“默认存在”的东西会变得非常模糊。&lt;/p&gt;
&lt;p&gt;于是重构的第一步，通常不是拆业务，而是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;把依赖全部显式化&lt;/li&gt;
&lt;li&gt;通过构造函数注入&lt;/li&gt;
&lt;li&gt;用参数而不是环境传递上下文&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;你会发现，​&lt;strong&gt;很多你以为是“业务复杂”的地方，其实只是依赖不清晰&lt;/strong&gt;。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;接下来，一个很典型的变化是：&lt;strong&gt;把“做事情的人”和“存数据的人”分开&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;在 PHP 中，一个 Service 里既：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;决定“要不要做”&lt;/li&gt;
&lt;li&gt;又知道“数据怎么拿”&lt;/li&gt;
&lt;li&gt;还顺便“把状态存回去”&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这种聚合在 PHP 中很常见，也不算坏。&lt;/p&gt;
&lt;p&gt;但在 Go 中，你会更倾向于：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Repository 只负责数据访问&lt;/li&gt;
&lt;li&gt;Service 只负责编排业务规则&lt;/li&gt;
&lt;li&gt;数据结构本身尽量保持“被动”&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这并不是为了“层次感好看”，而是为了让每一部分在并发和测试中都更容易被控制。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;再往下，你会开始重构​&lt;strong&gt;函数的形态&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;很多 PHP 方法的典型特征是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;入参少&lt;/li&gt;
&lt;li&gt;内部读取大量外部状态&lt;/li&gt;
&lt;li&gt;返回值模糊（成功 / 失败靠约定）&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;在 Go 化过程中，这类方法往往会被改造成：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;入参明确、甚至略显啰嗦&lt;/li&gt;
&lt;li&gt;所需信息全部通过参数传入&lt;/li&gt;
&lt;li&gt;返回 &lt;code&gt;(result, error)&lt;/code&gt;，而不是“顺带改变点什么”&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这种改变一开始会让人觉得“写起来好累”，但它带来的一个直接好处是：&lt;strong&gt;每个函数都更接近一个独立、可推理的单元&lt;/strong&gt;。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;还有一个非常实际的变化点，是​&lt;strong&gt;模块边界的确定方式&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;在 PHP 中，模块边界往往靠：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;目录结构&lt;/li&gt;
&lt;li&gt;命名约定&lt;/li&gt;
&lt;li&gt;框架的自动加载规则&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;而在 Go 中，package 本身就是边界。&lt;/p&gt;
&lt;p&gt;你不能随意跨包访问未导出的东西，这会迫使你认真思考：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;哪些是模块的“对外承诺”&lt;/li&gt;
&lt;li&gt;哪些只是内部实现细节&lt;/li&gt;
&lt;li&gt;哪些结构根本不应该被别人看到&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;于是，重构的结果往往不是“代码更多了”，而是&lt;strong&gt;暴露出来的东西更少了&lt;/strong&gt;。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;如果把整个 Go 化重构的过程压缩成一个思路，其实可以是这样：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;先显式化依赖&lt;/li&gt;
&lt;li&gt;再明确数据的所有权&lt;/li&gt;
&lt;li&gt;最后才是拆分和组织业务逻辑&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这和“把 PHP 代码翻译成 Go”是完全不同的路线。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;十六、部署与稳定性&lt;/h2&gt;
&lt;h3&gt;71. Go 程序的启动与退出流程&lt;/h3&gt;
&lt;p&gt;一开始我并没有把 Go 程序当成一个“有生命周期的进程”来看。&lt;/p&gt;
&lt;p&gt;在我的直觉里，它更像是：启动 → 执行 → 结束，只是执行时间变长了而已。&lt;/p&gt;
&lt;p&gt;直到我真的开始思考部署，这个理解才慢慢发生偏移。&lt;/p&gt;
&lt;p&gt;一个最简单的 Go 程序是这样的：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;package main

import &quot;fmt&quot;

func main() {
	fmt.Println(&quot;hello world&quot;)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这段代码看起来几乎没有任何信息量，但它其实已经隐含了一件事：&lt;strong&gt;进程一启动，就会一路执行到&lt;/strong&gt; &lt;strong&gt;​&lt;code&gt;main()&lt;/code&gt;​&lt;/strong&gt; ​ &lt;strong&gt;，中间没有可以被忽略的阶段。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;如果我加上一点初始化代码：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func init() {
	fmt.Println(&quot;init something&quot;)
}

func main() {
	fmt.Println(&quot;start service&quot;)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;那么启动顺序是确定的，而且完全写在代码里。&lt;/p&gt;
&lt;p&gt;依赖包加载完成后，&lt;code&gt;init&lt;/code&gt;​ 会被调用，随后进入 &lt;code&gt;main()&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;这里不存在“框架接管启动流程”的感觉，程序是直接面对操作系统开始运行的。&lt;/p&gt;
&lt;p&gt;这件事让我慢慢意识到：&lt;strong&gt;Go 程序的启动逻辑，几乎等同于这个进程真实发生的启动行为。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;你在 &lt;code&gt;main&lt;/code&gt; 里写了什么，进程启动时就做了什么；你没有写的事情，系统也不会替你补上。&lt;/p&gt;
&lt;p&gt;这种“直接感”在退出时同样明显。&lt;/p&gt;
&lt;p&gt;在 Go 里，&lt;code&gt;main()&lt;/code&gt; 函数一旦返回，整个进程就结束了。没有一个默认的“善后阶段”，也没有一个全局的退出回调。比如下面这段代码：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func main() {
	go func() {
		for {
			fmt.Println(&quot;working...&quot;)
		}
	}()

	fmt.Println(&quot;main exit&quot;)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;​&lt;code&gt;main&lt;/code&gt;​ 打印完 &lt;code&gt;&quot;main exit&quot;&lt;/code&gt; 之后，进程立刻退出，后台 goroutine 会被直接终止。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;它们并不会因为“还在工作中”而获得额外的存活时间。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这件事一开始会让我有点不适应，但后来反而觉得它很诚实。&lt;/p&gt;
&lt;p&gt;Go 并不会假装帮你处理好退出时的复杂情况，它只提供一个非常清晰的规则：&lt;strong&gt;​&lt;code&gt;main&lt;/code&gt;​&lt;/strong&gt;​ &lt;strong&gt;结束，世界就结束。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这也意味着，&lt;code&gt;defer&lt;/code&gt;、资源释放、连接关闭这些事情，只有在你明确设计退出路径时才会发生。Go 不会在进程退出前偷偷帮你“收拾一下”。&lt;/p&gt;
&lt;p&gt;慢慢地，我开始把 Go 程序理解为一段连续存在的时间，而不是一连串被触发的执行片段。&lt;/p&gt;
&lt;p&gt;程序从启动那一刻起，就已经作为一个长期存在的进程站在系统里；而当它退出时，也意味着你承认这段时间该被完整地终结。&lt;/p&gt;
&lt;p&gt;也正是在这种视角下，“如何退出”才第一次变成一个值得认真对待的问题，而不只是 &lt;code&gt;return&lt;/code&gt; 一下那么简单。&lt;/p&gt;
&lt;p&gt;后面再去看信号、优雅关闭、systemd，反而变成了顺理成章的事情。&lt;/p&gt;
&lt;h3&gt;72. 信号处理与优雅关闭&lt;/h3&gt;
&lt;p&gt;当我意识到 &lt;code&gt;main&lt;/code&gt; 一结束，整个进程就会被毫不犹豫地终止时，“退出”这件事开始变得不那么简单了。&lt;/p&gt;
&lt;p&gt;因为在真实环境里，程序几乎从来不是自己决定要不要退出的。&lt;/p&gt;
&lt;p&gt;更多时候，退出是被通知的。&lt;/p&gt;
&lt;p&gt;比如端口要被释放、服务要重启、机器要关机。&lt;/p&gt;
&lt;p&gt;对程序来说，这些都不是逻辑错误，而是外部世界在告诉你：&lt;strong&gt;现在该停下来了。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;在 Go 里，这种“通知”通常以信号的形式出现。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;go run main.go
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;当我在终端里按下 &lt;code&gt;Ctrl+C&lt;/code&gt;，进程并不是“正常 return 了”，而是收到了一个来自操作系统的信号。&lt;/p&gt;
&lt;p&gt;如果程序什么都不做，那么默认行为只有一个：&lt;strong&gt;立刻退出。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这时候我才慢慢意识到一个事实：&lt;strong&gt;所谓的“优雅关闭”，并不是 Go 自动提供的能力，而是一种你需要主动参与的协议。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;如果我什么都不写，Go 程序对信号的态度其实非常简单：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func main() {
	select {}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个程序看起来可以一直运行，但当收到退出信号时，它会直接被终止，没有任何过渡，也没有任何清理逻辑。&lt;/p&gt;
&lt;p&gt;它并不知道“现在退出意味着什么”。&lt;/p&gt;
&lt;p&gt;于是，处理信号这件事就变成了：&lt;strong&gt;在进程被强制终止之前，给自己一个“知道要结束了”的机会。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;在 Go 里，这个机会通常是这样拿到的：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()

&amp;#x3C;-ctx.Done()
fmt.Println(&quot;收到退出信号，准备关闭&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里让我印象很深的一点是，Go 并没有引入一套“特殊的退出生命周期”，而是把信号直接映射成了 &lt;code&gt;context&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;退出不再是一个异类事件，而是一次上下文的取消。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;当我开始用这种方式看待信号时，思路会变得非常统一：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;启动服务时创建上下文；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;收到信号时取消上下文；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;正在工作的 goroutine 感知到取消，自行决定如何收尾。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;比如这样：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;go func(ctx context.Context) {
	for {
		select {
		case &amp;#x3C;-ctx.Done():
			fmt.Println(&quot;worker exit&quot;)
			return
		default:
			// do work
		}
	}
}(ctx)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里并没有“强制终止”的感觉。&lt;strong&gt;goroutine 是自己意识到“该结束了”，而不是被突然杀掉。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这也是我后来理解“优雅”的方式：不是程序永远不出问题，而是当问题不可避免地发生时，它有一条清晰、可预测的退出路径。&lt;/p&gt;
&lt;p&gt;这套机制看起来并不复杂，但它带来的变化很明显。&lt;/p&gt;
&lt;p&gt;退出不再是 &lt;code&gt;main&lt;/code&gt; 的一个 return，而是一个会向整个系统扩散的信号；&lt;/p&gt;
&lt;p&gt;关闭不再是瞬间发生的事情，而是一个可以被观察、被等待的过程。&lt;/p&gt;
&lt;p&gt;当我把信号、上下文和 goroutine 放在同一个时间线上看时，Go 的设计开始显得非常克制。&lt;/p&gt;
&lt;p&gt;它不试图帮你“自动优雅”，只是提供了一套足够简单的工具，让你明确地写下：&lt;strong&gt;什么时候该停、谁需要知道、以及要怎么停。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;也正是因为这种克制，后面再去看 systemd 对 Go 服务的管理方式时，我反而觉得它们是天然对齐的，而不是两套勉强拼在一起的机制。&lt;/p&gt;
&lt;h3&gt;73. systemd 部署 Go 服务的实践&lt;/h3&gt;
&lt;p&gt;在真正把 Go 服务放到 systemd 下面运行之前，我对 systemd 的理解其实非常工具化：写个 unit 文件，能跑起来就行。&lt;/p&gt;
&lt;p&gt;但当前面已经把“启动”“退出”“信号”这些事情想清楚之后，再回头看 systemd，视角会发生明显变化。&lt;/p&gt;
&lt;p&gt;systemd 并不是在“托管你的代码”，它只是在管理一个进程。&lt;/p&gt;
&lt;p&gt;而 Go 写出来的服务，本身就非常像 systemd 期待的那种进程。&lt;/p&gt;
&lt;p&gt;一个最基础的 unit 文件大概是这样的：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ini&quot;&gt;[Unit]
Description=My Go Service
After=network.target

[Service]
ExecStart=/usr/local/bin/my-service
Restart=on-failure

[Install]
WantedBy=multi-user.target
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;第一次看到这种配置时，我会下意识地找一些“生命周期钩子”，比如启动前、关闭后之类的东西。&lt;/p&gt;
&lt;p&gt;但 systemd 的思路其实很简单：&lt;strong&gt;它只关心进程是否存在，以及它是如何退出的。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;​&lt;code&gt;ExecStart&lt;/code&gt; 启动进程，进程活着，服务就活着；&lt;/p&gt;
&lt;p&gt;进程退出，systemd 只根据退出结果来决定下一步要不要重启。&lt;/p&gt;
&lt;p&gt;这时候前面那些关于 Go 启动与退出的理解，开始变得非常实用。&lt;/p&gt;
&lt;p&gt;因为对 systemd 来说，Go 程序的 &lt;code&gt;main()&lt;/code&gt; 就是整个服务的生命线。&lt;/p&gt;
&lt;p&gt;当 systemd 需要停止服务时，它不会“调用你的关闭函数”，而是向进程发送信号。&lt;/p&gt;
&lt;p&gt;如果你的 Go 程序已经处理了这些信号，那么 systemd 发出的停止请求，就会自然地变成一次可控的退出流程。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ini&quot;&gt;[Service]
ExecStart=/usr/local/bin/my-service
ExecStop=/bin/kill -SIGTERM $MAINPID
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但实际上，即使你不显式写 &lt;code&gt;ExecStop&lt;/code&gt;，systemd 默认发送的也是类似的终止信号。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;systemd 和 Go 之间的“对话语言”，从头到尾只有信号。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这也是为什么在 Go 服务里处理信号显得如此重要。&lt;/p&gt;
&lt;p&gt;不是为了“代码优雅”，而是为了让外部的进程管理工具，能够准确地理解你的状态。&lt;/p&gt;
&lt;p&gt;当 Go 程序在收到信号后，选择有序地关闭 goroutine、释放资源、最终退出时，systemd 看到的只是一个“正常结束的进程”。&lt;/p&gt;
&lt;p&gt;而这对于 systemd 来说，已经足够了。&lt;/p&gt;
&lt;p&gt;我后来才意识到一件事：&lt;strong&gt;systemd 并不需要你告诉它“我已经优雅关闭了”，它只需要看到进程在合理时间内退出。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;如果你的程序迟迟不退出，systemd 会认为它“卡住了”；&lt;/p&gt;
&lt;p&gt;如果你的程序直接被杀死，systemd 会认为它“异常结束”。&lt;/p&gt;
&lt;p&gt;剩下的判断，全部来自于你对进程退出路径的设计。&lt;/p&gt;
&lt;p&gt;也正因为这种关系非常直接，Go 服务在 systemd 下反而显得很轻松。&lt;/p&gt;
&lt;p&gt;没有适配层，没有特殊协议，也不需要额外的运行时支持。&lt;/p&gt;
&lt;p&gt;Go 程序只是安静地作为一个进程存在，而 systemd 则用它最擅长的方式看护这个进程。&lt;/p&gt;
&lt;p&gt;当我把这些细节连在一起看时，会有一种很明确的感觉：这并不是“systemd 很适合 Go”，而是 &lt;strong&gt;Go 写出来的服务，本身就符合 systemd 对服务的想象。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;74. 为什么 Go 天然适合做常驻服务&lt;/h3&gt;
&lt;p&gt;当我把 Go 程序的启动、退出、信号处理，以及 systemd 的行为放在同一条时间线上之后，“Go 适合做常驻服务”这件事反而不再像一句评价，而更像一个结果。&lt;/p&gt;
&lt;p&gt;Go 写出来的程序，本身就是一个非常完整的进程。&lt;/p&gt;
&lt;p&gt;它从启动那一刻起，就已经准备好长期存在，而不是等待某个外部事件来“激活自己”。&lt;/p&gt;
&lt;p&gt;​&lt;code&gt;main()&lt;/code&gt; 不只是一个入口函数，它几乎等同于整个服务的生命周期。&lt;/p&gt;
&lt;p&gt;你在这里初始化资源、启动 goroutine、监听端口，也在这里等待退出信号、关闭通道、结束进程。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;服务的开始和结束，全部发生在你看得见的代码里。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这种模型对常驻服务来说非常自然。&lt;/p&gt;
&lt;p&gt;因为常驻服务关心的，本来就不是“某一次请求”，而是一段持续存在的时间。&lt;/p&gt;
&lt;p&gt;Go 对并发的处理方式，也让这种“长期存在”变得轻量。&lt;/p&gt;
&lt;p&gt;goroutine 不需要你为它们分配明确的线程资源，它们更像是一种随时可以启动、随时可以结束的工作单元。&lt;/p&gt;
&lt;p&gt;这让服务在运行过程中可以不断变化形态，而不必承担过高的管理成本。&lt;/p&gt;
&lt;p&gt;更重要的是，Go 对“退出”的态度非常明确。&lt;/p&gt;
&lt;p&gt;进程什么时候结束，不是由框架决定的，而是由你写下的退出路径决定的。&lt;/p&gt;
&lt;p&gt;信号不会被包装成复杂的生命周期事件，而是直接转化为上下文的取消。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;常驻服务最怕的，其实不是崩溃，而是状态不清楚。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;不知道自己现在是不是还在工作，也不知道外部世界希望它什么时候停下来。&lt;/p&gt;
&lt;p&gt;Go 在这一点上显得非常克制。&lt;/p&gt;
&lt;p&gt;它不试图替你隐藏复杂性，只是提供一套足够简单、足够稳定的抽象，让你把这些状态写清楚。&lt;/p&gt;
&lt;p&gt;当 systemd 把 Go 服务当成一个普通进程来看待时，Go 也正好是以一个普通、但非常自洽的进程在运行。&lt;/p&gt;
&lt;p&gt;双方不需要额外约定，也不需要特殊照顾。&lt;/p&gt;
&lt;p&gt;回过头来看，我会发现：Go 并不是“为了做服务而设计的语言”，但它对进程、并发、退出的理解，刚好与常驻服务的需求高度重合。&lt;/p&gt;
&lt;p&gt;所以这种“天然适合”，并不是来自某个单一特性，而是来自一种一致的设计取向：&lt;strong&gt;程序从启动开始就认真地存在着，并且对自己的结束负责。&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;十七、并发型实战项目&lt;/h2&gt;
&lt;h3&gt;75. 并发 webhook 接收与处理服务&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;当前问题存在示例代码，可以前往&lt;a href=&quot;https://github.com/zxc7563598/go-lab/commit/d33b9956c41dd5d68ebdd3e17aa4961cea5852c1&quot;&gt;GitHub查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;package main

import (
	&quot;encoding/json&quot;
	&quot;fmt&quot;
	&quot;math/rand&quot;
	&quot;net/http&quot;
	&quot;sync&quot;
	&quot;time&quot;
)

// WebhookPayload 定义接收的数据结构
type WebhookPayload struct {
	Event     string         `json:&quot;event&quot;`
	Timestamp time.Time      `json:&quot;timestamp&quot;`
	Data      map[string]any `json:&quot;data&quot;`
	Signature string         `json:&quot;signature&quot;`
}

// WebhookProcessor 表示一个典型的“生产者-消费者”模型：
// HTTP 层作为生产者，worker 池作为消费者。
// 这个结构体本身并不关心 HTTP，只关心如何并发、安全地处理任务。
type WebhookProcessor struct {
	// queue 是任务缓冲队列，用来承接 HTTP 请求与后台处理之间的速率差
	// 使用带缓冲的 channel，可以避免 webhook 高峰期直接把服务压垮
	queue chan WebhookPayload

	// workers 表示 worker goroutine 的数量
	// 在这个模型里，它同时也代表了最大并发处理能力
	workers int

	// wg 用来等待所有 worker 正常退出
	// 这使得 Stop() 可以是一个“阻塞直到完全停止”的操作
	wg sync.WaitGroup

	// stopChan 用来广播“停止信号”
	// 一旦关闭，所有 worker 都应该开始走退出路径
	stopChan chan struct{}

	// processing 作为一个信号量（semaphore），用于限制同时处理的任务数
	// 在本例中，由于 worker 数量已经限制了并发度，这个 channel 实际上是演示用途
	processing chan struct{}
}

// NewWebhookProcessor 创建新的处理器
func NewWebhookProcessor(workers int, queueSize int) *WebhookProcessor {
	return &amp;#x26;WebhookProcessor{
		queue:      make(chan WebhookPayload, queueSize),
		workers:    workers,
		stopChan:   make(chan struct{}),
		processing: make(chan struct{}, workers),
	}
}

// Start 启动 worker 池。
// 每个 worker 都是一个独立的 goroutine，
// 它们会持续从 queue 中取任务，直到收到 stop 信号。
func (wp *WebhookProcessor) Start() {
	for i := 0; i &amp;#x3C; wp.workers; i++ {
		wp.wg.Add(1)
		go wp.worker(i)
	}
	fmt.Printf(&quot;启动 %d 个 worker 处理 webhook\n&quot;, wp.workers)
}

// Stop 用来通知所有 worker 停止工作，并等待它们退出。
// 这里并不会强制中断正在处理的任务，而是给 worker 一个“该结束了”的信号。
func (wp *WebhookProcessor) Stop() {
	close(wp.stopChan)
	wp.wg.Wait()
	fmt.Printf(&quot;所有 worker 已停止\n&quot;)
}

// worker 是实际执行 webhook 处理逻辑的 goroutine。
// 每个 worker 会在一个循环中等待任务或停止信号。
func (wp *WebhookProcessor) worker(id int) {
	defer wp.wg.Done()

	for {
		select {
		// 一旦 stopChan 被关闭，所有 worker 都会走到这里并退出
		case &amp;#x3C;-wp.stopChan:
			fmt.Printf(&quot;Worker %d 收到停止信号，退出\n&quot;, id)
			return

		// 从任务队列中取出一个 payload
		// 如果此时队列为空，worker 会阻塞在这里等待新任务
		case payload := &amp;#x3C;-wp.queue:
			// 通过向 processing channel 写入一个值，表示“占用一个处理名额”
			wp.processing &amp;#x3C;- struct{}{}

			fmt.Printf(&quot;Worker %d 开始处理事件: %s\n&quot;, id, payload.Event)

			// 实际的业务处理逻辑
			wp.processPayload(id, payload)

			// 释放处理名额
			&amp;#x3C;-wp.processing
		}
	}
}

// AddPayload 尝试将 webhook payload 放入处理队列。
// 这里使用非阻塞写入，是为了避免 HTTP 请求被无限期卡住。
func (wp *WebhookProcessor) AddPayload(payload WebhookPayload) error {
	select {
	case wp.queue &amp;#x3C;- payload:
		fmt.Printf(&quot;事件 %s 已加入队列\n&quot;, payload.Event)
		return nil
	default:
		// 当队列已满时，直接返回错误，由 HTTP 层决定如何响应
		return fmt.Errorf(&quot;处理队列已满&quot;)
	}
}

// processPayload 实际处理webhook的业务逻辑
func (wp *WebhookProcessor) processPayload(workerID int, payload WebhookPayload) {
	// 模拟随机处理时间
	r := rand.New(rand.NewSource(time.Now().UnixNano()))
	randomNum := r.Intn(5) + 1
	time.Sleep(time.Second * time.Duration(randomNum))
	// 打印payload
	dataJSON, _ := json.MarshalIndent(payload.Data, &quot;&quot;, &quot;  &quot;)
	fmt.Printf(&quot;Worker %d 处理完成 - 事件: %s, 时间: %s\n数据: %s\n&quot;, workerID, payload.Event, payload.Timestamp.Format(time.DateTime), string(dataJSON))
}

// healthCheck 检查接口是否正常
func healthCheck(w http.ResponseWriter, r *http.Request) {
	w.Header().Set(&quot;Content-Type&quot;, &quot;application/json&quot;)
	json.NewEncoder(w).Encode(map[string]any{
		&quot;status&quot;: &quot;healthy&quot;,
		&quot;time&quot;:   time.Now().Format(time.DateTime),
	})
}

// webhookHandler 是 HTTP 层与处理器之间的“边界”。
// 它的职责非常明确：
// 1. 校验请求
// 2. 解析数据
// 3. 尝试入队
// 4. 尽快返回响应
func webhookHandler(processor *WebhookProcessor) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		if r.Method != http.MethodPost {
			http.Error(w, &quot;只支持 POST 请求&quot;, http.StatusMethodNotAllowed)
			return
		}

		// 这里对 Content-Type 的判断是简化版本
		// 真实环境中可能需要兼容 charset 等参数
		if r.Header.Get(&quot;Content-Type&quot;) != &quot;application/json&quot; {
			http.Error(w, &quot;只支持 application/json&quot;, http.StatusUnsupportedMediaType)
			return
		}

		var payload WebhookPayload
		if err := json.NewDecoder(r.Body).Decode(&amp;#x26;payload); err != nil {
			http.Error(w, &quot;无效的 JSON 格式&quot;, http.StatusBadRequest)
			return
		}

		// 补充一些基础字段，避免后续处理时出现空值
		if payload.Timestamp.IsZero() {
			payload.Timestamp = time.Now()
		}
		if payload.Event == &quot;&quot; {
			payload.Event = &quot;未知&quot;
		}

		// 尝试将任务交给后台处理器
		if err := processor.AddPayload(payload); err != nil {
			http.Error(w, err.Error(), http.StatusServiceUnavailable)
			return
		}

		// webhook 的最佳实践通常是“尽快确认已接收”
		w.WriteHeader(http.StatusAccepted)
		w.Header().Set(&quot;Content-Type&quot;, &quot;application/json&quot;)
		json.NewEncoder(w).Encode(map[string]any{
			&quot;status&quot;:  &quot;accepted&quot;,
			&quot;message&quot;: &quot;webhook 已接收&quot;,
			&quot;event&quot;:   payload.Event,
		})
	}
}

// metricsHandler 监控信息：显示队列状态
func metricsHandler(processor *WebhookProcessor) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set(&quot;Content-Type&quot;, &quot;application/json&quot;)
		json.NewEncoder(w).Encode(map[string]any{
			&quot;queue_length&quot;:     len(processor.queue),
			&quot;queue_capacity&quot;:   cap(processor.queue),
			&quot;processing_count&quot;: processor.workers,
			&quot;timestamp&quot;:        time.Now().Format(time.DateTime),
		})
	}
}

func main() {
	// 配置参数
	port := &quot;:8080&quot;
	workers := 5
	queueSize := 100

	// 创建webhook处理器
	processor := NewWebhookProcessor(workers, queueSize)

	// 启动worker池
	processor.Start()
	defer processor.Stop()

	// 设置http路由
	http.HandleFunc(&quot;/webhook&quot;, webhookHandler(processor))
	http.HandleFunc(&quot;/health&quot;, healthCheck)
	http.HandleFunc(&quot;/metrics&quot;, metricsHandler(processor))

	// 启动http服务
	fmt.Printf(&quot;http服务启动在: http://localhost%s\n&quot;, port)
	fmt.Println(&quot;可用端点:&quot;)
	fmt.Println(&quot;  POST /webhook - 接收webhook&quot;)
	fmt.Println(&quot;  GET  /health  - 健康检查&quot;)
	fmt.Println(&quot;  GET  /metrics - 查看队列状态&quot;)
	if err := http.ListenAndServe(port, nil); err != nil {
		fmt.Println(&quot;服务器启动失败&quot;, err)
	}

}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;76. 并发执行 shell 的任务调度器&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;当前问题存在示例代码，可以前往&lt;a href=&quot;https://github.com/zxc7563598/go-lab/commit/8a8176c540a7927cf16e78f1b00b31839590fb4f&quot;&gt;GitHub查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;package main

import (
	&quot;bufio&quot;
	&quot;bytes&quot;
	&quot;context&quot;
	&quot;fmt&quot;
	&quot;io&quot;
	&quot;log&quot;
	&quot;os&quot;
	&quot;os/exec&quot;
	&quot;os/signal&quot;
	&quot;strings&quot;
	&quot;sync&quot;
	&quot;syscall&quot;
	&quot;time&quot;

	&quot;github.com/fatih/color&quot;
)

// TaskStatus 任务状态
type TaskStatus int

const (
	StatusPending   TaskStatus = iota // 0
	StatusRunning                     // 1
	StatusSuccess                     // 2
	StatusFailed                      // 3
	StatusTimeout                     // 4
	StatusCancelled                   // 5
)

func (s TaskStatus) String() string {
	switch s {
	case StatusPending:
		return &quot;⏳ 待处理&quot;
	case StatusRunning:
		return &quot;🚀 运行中&quot;
	case StatusSuccess:
		return &quot;✅ 成功&quot;
	case StatusFailed:
		return &quot;❌ 失败&quot;
	case StatusTimeout:
		return &quot;⏰ 超时&quot;
	case StatusCancelled:
		return &quot;🚫 取消&quot;
	default:
		return &quot;❓ 未知&quot;
	}
}

// Task 任务定义
type Task struct {
	ID           string        // 任务ID
	Name         string        // 任务名称
	Cmd          string        // 执行命令
	Args         []string      // 命令参数
	Timeout      time.Duration // 超时时间
	RetryCount   int           // 重试次数
	RetryDelay   time.Duration // 重试延迟
	MaxOutput    int           // 最大输出行数
	Env          []string      // 环境变量
	WorkDir      string        // 工作目录
	Dependencies []string      // 依赖的任务ID
}

// TaskResult 任务执行结果
type TaskResult struct {
	TaskID     string        // 任务ID
	TaskName   string        // 任务名称
	Status     TaskStatus    // 任务状态
	StartTime  time.Time     // 开始时间
	EndTime    time.Time     // 结束时间
	Duration   time.Duration // 持续时间
	ExitCode   int           // 退出code
	Output     string        // 输出内容
	Error      error         // 错误信息
	RetryCount int           // 重试次数
}

// Scheduler 调度器
type Scheduler struct {
	maxWorkers      int                    // 最大并发数
	tasks           map[string]*Task       // 所有任务
	taskResults     map[string]*TaskResult // 所有任务结果
	taskQueue       chan *Task             // 任务队列
	taskResultQueue chan *TaskResult       // 结果队列
	wg              sync.WaitGroup         // 等待组
	mu              sync.Mutex             // 读写锁
	ctx             context.Context        // 上下文
	cancel          context.CancelFunc     // 取消函数
	isRunning       bool                   // 是否正在运行
	completedTasks  map[string]bool        // 已完成任务
}

// NewScheduler 创建调度器
func NewScheduler(maxWorkers int) *Scheduler {
	ctx, cancel := context.WithCancel(context.Background())
	return &amp;#x26;Scheduler{
		maxWorkers:      maxWorkers,
		tasks:           make(map[string]*Task),
		taskResults:     make(map[string]*TaskResult),
		taskQueue:       make(chan *Task, 100),
		taskResultQueue: make(chan *TaskResult, 100),
		ctx:             ctx,
		cancel:          cancel,
		completedTasks:  make(map[string]bool),
	}
}

// AddTask 添加任务
func (s *Scheduler) AddTask(task *Task) error {
	s.mu.Lock()
	defer s.mu.Unlock()

	if task.ID == &quot;&quot; {
		task.ID = fmt.Sprintf(&quot;task-%d&quot;, len(s.tasks)+1)
	}
	if task.Name == &quot;&quot; {
		task.Name = task.ID
	}
	if task.Timeout == 0 {
		task.Timeout = 5 * time.Minute
	}
	if task.MaxOutput == 0 {
		task.MaxOutput = 5000
	}
	s.tasks[task.ID] = task
	return nil
}

// checkDependencies 检查任务依赖
func (s *Scheduler) checkDependencies() error {
	// 根据 Dependencies 去检查需要的任务是否存在在队列中
	for _, task := range s.tasks {
		for _, depID := range task.Dependencies {
			if _, exists := s.tasks[depID]; !exists {
				return fmt.Errorf(&quot;任务 %s 依赖的任务 %s 不存在&quot;, task.ID, depID)
			}
		}
	}
	return nil
}

// taskDispatcher 任务分发器
func (s *Scheduler) taskDispatcher() {
	// 检查依赖
	if err := s.checkDependencies(); err != nil {
		log.Printf(&quot;检查依赖失败, %v&quot;, err)
		return
	}
	// 没有依赖的任务加入队列
	// 有依赖的任务会在没有依赖的任务完成后执行
	s.mu.Lock()
	for _, task := range s.tasks {
		if len(task.Dependencies) == 0 {
			s.taskQueue &amp;#x3C;- task
		}
	}
	s.mu.Unlock()
}

// copyAndLog 复制并输出记录
func (s *Scheduler) copyAndLog(dst io.Writer, src io.Reader, prefix, taskName string) {
	scanner := bufio.NewScanner(src)
	for scanner.Scan() {
		line := scanner.Text()
		io.WriteString(dst, line+&quot;\n&quot;)
		// 实时日志
		log.Printf(&quot;[%s] %s: %s&quot;, taskName, prefix, line)
	}
}

// runCommand 执行shell命令
func (s *Scheduler) runCommand(task *Task, output io.Writer) (int, error) {
	ctx, cancel := context.WithTimeout(s.ctx, task.Timeout)
	defer cancel()

	// 创建命令
	var cmd *exec.Cmd
	if len(task.Args) &gt; 0 {
		cmd = exec.CommandContext(ctx, task.Cmd, task.Args...)
	} else {
		cmd = exec.CommandContext(ctx, &quot;sh&quot;, &quot;-c&quot;, task.Cmd)
	}

	// 设置工作目录
	if task.WorkDir != &quot;&quot; {
		cmd.Dir = task.WorkDir
	}

	// 设置环境变量
	if len(task.Env) &gt; 0 {
		cmd.Env = append(os.Environ(), cmd.Env...)
	}

	// 设置输出
	stdoutPipe, err := cmd.StdoutPipe()
	if err != nil {
		return -1, err
	}
	stderrPipe, err := cmd.StderrPipe()
	if err != nil {
		return -1, err
	}

	// 启动命令
	if err := cmd.Start(); err != nil {
		return -1, err
	}

	// 并发读取 stdout 和 stderr
	var wg sync.WaitGroup
	wg.Add(2)
	go func() {
		defer wg.Done()
		s.copyAndLog(output, stdoutPipe, &quot;STDOUT&quot;, task.Name)
	}()
	go func() {
		defer wg.Done()
		s.copyAndLog(output, stderrPipe, &quot;STDERR&quot;, task.Name)
	}()
	wg.Wait()

	// 等待命令完成
	err = cmd.Wait()
	exitCode := cmd.ProcessState.ExitCode()
	if ctx.Err() == context.DeadlineExceeded {
		return exitCode, fmt.Errorf(&quot;任务执行超时(限时: %v)&quot;, task.Timeout)
	}
	return exitCode, err
}

// trimOutput 限制输出大小
func (s *Scheduler) trimOutput(output string, maxLines int) string {
	lines := bytes.Split([]byte(output), []byte(&quot;\n&quot;))
	if len(lines) &amp;#x3C;= maxLines {
		return output
	}

	// 保留开头和结尾
	keep := maxLines / 2
	firstPart := lines[:keep]
	lastPart := lines[len(lines)-keep:]

	var result []byte
	result = append(result, bytes.Join(firstPart, []byte(&quot;\n&quot;))...)
	result = append(result, []byte(&quot;\n... (忽略中间内容) ...\n&quot;)...)
	result = append(result, bytes.Join(lastPart, []byte(&quot;\n&quot;))...)

	return string(result)
}

// executeTask 执行单个任务
func (s *Scheduler) executeTask(workerID int, task *Task) *TaskResult {
	result := &amp;#x26;TaskResult{
		TaskID:     task.ID,
		TaskName:   task.Name,
		Status:     StatusRunning,
		StartTime:  time.Now(),
		RetryCount: 0,
	}

	log.Printf(&quot;Worker-%d 开始执行%s: %s&quot;, workerID, task.Name, task.Cmd)

	// 执行命令
	var output bytes.Buffer
	var err error
	var exitCode int

	for attempt := 0; attempt &amp;#x3C;= task.RetryCount; attempt++ {
		if attempt &gt; 0 {
			log.Printf(&quot;任务 %s 第 %d 次重试...&quot;, task.Name, attempt)
			time.Sleep(task.RetryDelay)
		}

		result.RetryCount = attempt
		output.Reset()
		exitCode, err = s.runCommand(task, &amp;#x26;output)

		if err == nil {
			result.Status = StatusSuccess
			break
		}

		if attempt == task.RetryCount {
			result.Status = StatusFailed
		}
	}

	result.EndTime = time.Now()
	result.Duration = result.EndTime.Sub(result.StartTime)
	result.ExitCode = exitCode
	result.Output = s.trimOutput(output.String(), task.MaxOutput)
	result.Error = err

	return result
}

// worker 工作协程
func (s *Scheduler) worker(id int) {
	defer s.wg.Done()
	for {
		select {
		case &amp;#x3C;-s.ctx.Done():
			return
		case task := &amp;#x3C;-s.taskQueue:
			result := s.executeTask(id, task)
			s.taskResultQueue &amp;#x3C;- result
		}
	}
}

// printResult 打印任务结果
func (s *Scheduler) printResult(result *TaskResult) {
	var statusColor *color.Color

	switch result.Status {
	case StatusSuccess:
		statusColor = color.New(color.FgGreen, color.Bold)
	case StatusFailed:
		statusColor = color.New(color.FgRed, color.Bold)
	case StatusCancelled:
		statusColor = color.New(color.FgYellow, color.Bold)
	default:
		statusColor = color.New(color.FgWhite)
	}

	statusColor.Printf(&quot;\n任务完成: %s (%s)\n&quot;, result.TaskName, result.TaskID)
	fmt.Printf(&quot;  状态: %s&quot;, result.Status)
	fmt.Printf(&quot;  耗时: %v&quot;, result.Duration)
	fmt.Printf(&quot;  开始: %s&quot;, result.StartTime.Format(time.DateTime))
	fmt.Printf(&quot;  结束: %s&quot;, result.EndTime.Format(time.DateTime))
	fmt.Printf(&quot;  退出码: %d&quot;, result.ExitCode)
	fmt.Printf(&quot;  重试次数: %d&quot;, result.RetryCount)

	if result.Error != nil {
		fmt.Printf(&quot;  错误: %v\n&quot;, result.Error)
	}

	if result.Output != &quot;&quot; {
		fmt.Println(&quot;  输出预览:&quot;)
		lines := bytes.SplitN([]byte(result.Output), []byte(&quot;\n&quot;), 6)
		for i, line := range lines {
			if i &gt;= 5 {
				fmt.Println(&quot;    ...(更多输出请查看完整日志)...&quot;)
			}
			if len(line) &gt; 0 {
				fmt.Printf(&quot;    %s\n&quot;, line)
			}
		}
	}
	fmt.Println()
}

// checkDependentTasks 检查依赖任务
func (s *Scheduler) checkDependentTasks() {
	s.mu.Lock()
	defer s.mu.Unlock()

	for _, task := range s.tasks {
		// 如果任务已经在队列或已完成则跳过
		if s.completedTasks[task.ID] {
			continue
		}
		// 未进行任务依赖项是否全部满足
		allDepsCompleted := true
		for _, depID := range task.Dependencies {
			if !s.completedTasks[depID] {
				allDepsCompleted = false
				break
			}
		}
		// 如果依赖项项目全部满足，加入队列
		if allDepsCompleted &amp;#x26;&amp;#x26; len(task.Dependencies) &gt; 0 {
			// 标记已调度
			if !s.completedTasks[task.ID] {
				select {
				case s.taskQueue &amp;#x3C;- task:
					s.completedTasks[task.ID] = true
				default:
					log.Printf(&quot;队列任务已满, 任务 %s 等待调度&quot;, task.Name)
				}
			}
		}
	}
}

// resultProcessor 处理任务结果
func (s *Scheduler) resultProcessor() {
	for result := range s.taskResultQueue {
		s.mu.Lock()
		s.taskResults[result.TaskID] = result
		s.completedTasks[result.TaskID] = true
		s.mu.Unlock()
		// 打印结果
		s.printResult(result)
		// 检查是否有依赖此任务的任务可以执行
		s.checkDependentTasks()
	}
}

// AddTasks 批量添加任务
func (s *Scheduler) AddTasks(tasks ...*Task) {
	for _, task := range tasks {
		s.AddTask(task)
	}
}

// Start 启动调度器
func (s *Scheduler) Start() error {
	s.mu.Lock()
	if s.isRunning {
		s.mu.Unlock()
		return fmt.Errorf(&quot;程序已经在运行&quot;)
	}
	s.isRunning = true
	s.mu.Unlock()

	// 启动work
	for i := 0; i &amp;#x3C; s.maxWorkers; i++ {
		s.wg.Add(1)
		go s.worker(i)
	}

	// 启动结果处理器
	go s.resultProcessor()

	// 启动任务调器
	go s.taskDispatcher()

	log.Printf(&quot;调度器启动，最大并发数: %d&quot;, s.maxWorkers)
	return nil
}

// Stop 停止调度器
func (s *Scheduler) Stop() {
	log.Println(&quot;停止调度器...&quot;)
	s.cancel()
	s.wg.Wait()
	close(s.taskQueue)
	close(s.taskResultQueue)
	s.isRunning = false
	log.Println(&quot;调度器已停止&quot;)
}

// GetResults 获取所有任务结果
func (s *Scheduler) GetResults() map[string]*TaskResult {
	s.mu.Lock()
	defer s.mu.Unlock()

	results := make(map[string]*TaskResult)
	for k, v := range s.taskResults {
		results[k] = v
	}
	return results
}

// PrintSummary 打印汇总报告
func (s *Scheduler) PrintSummary() {
	results := s.GetResults()

	fmt.Println(&quot;\n&quot; + strings.Repeat(&quot;-&quot;, 60))
	fmt.Println(&quot;任务执行汇总报告&quot;)
	fmt.Println(strings.Repeat(&quot;-&quot;, 60))

	var totalTime time.Duration
	successCount := 0
	failedCount := 0

	for _, result := range results {
		totalTime += result.Duration
		if result.Status == StatusSuccess {
			successCount++
		} else {
			failedCount++
		}
	}

	fmt.Printf(&quot;任务总数: %d\n&quot;, len(results))
	fmt.Printf(&quot;成功: %d\n&quot;, successCount)
	fmt.Printf(&quot;失败: %d\n&quot;, failedCount)
	fmt.Printf(&quot;总耗时: %v\n&quot;, totalTime)
	fmt.Printf(&quot;平均耗时: %v\n&quot;, totalTime/time.Duration(len(results)))

	// 打印详细结果表格
	fmt.Println(&quot;\n详细结果:&quot;)
	fmt.Println(strings.Repeat(&quot;-&quot;, 100))
	fmt.Printf(&quot;%-20s %-15s %-12s %-10s %-30s\n&quot;, &quot;任务名称&quot;, &quot;状态&quot;, &quot;耗时&quot;, &quot;退出码&quot;, &quot;开始时间&quot;)
	fmt.Println(strings.Repeat(&quot;-&quot;, 100))
	for _, result := range results {
		statusStr := result.Status.String()
		if result.Status == StatusSuccess {
			statusStr = color.GreenString(statusStr)
		} else {
			statusStr = color.RedString(statusStr)
		}

		fmt.Printf(&quot;%-20s %-15s %-12v %-10d %-30s\n&quot;, result.TaskName, statusStr, result.Duration.Round(time.Millisecond), result.ExitCode, result.StartTime.Format(time.DateTime))
		fmt.Println(strings.Repeat(&quot;-&quot;, 100))
	}
}

func main() {
	// 创建调度器
	scheduler := NewScheduler(3)

	// 定义任务
	tasks := []*Task{
		{
			ID:         &quot;Test A&quot;,
			Name:       &quot;测试脚本A&quot;,
			Cmd:        &quot;sh ./shell/test.sh 5 测试脚本A&quot;,
			Timeout:    10 * time.Minute,
			RetryDelay: 3 * time.Second,
			RetryCount: 2,
		},
		{
			ID:         &quot;Test B&quot;,
			Name:       &quot;测试脚本B&quot;,
			Cmd:        &quot;sh ./shell/test.sh 3 测试脚本B&quot;,
			Timeout:    10 * time.Minute,
			RetryDelay: 3 * time.Second,
			RetryCount: 2,
		},
		{
			ID:         &quot;Test C&quot;,
			Name:       &quot;测试脚本C&quot;,
			Cmd:        &quot;sh ./shell/test.sh 2&quot;,
			Timeout:    10 * time.Minute,
			RetryDelay: 3 * time.Second,
			RetryCount: 2,
		},
		{
			ID:           &quot;Test D&quot;,
			Name:         &quot;测试脚本D&quot;,
			Cmd:          &quot;sh ./shell/test.sh 1 测试脚本D&quot;,
			Timeout:      10 * time.Minute,
			Dependencies: []string{&quot;Test A&quot;, &quot;Test B&quot;},
			RetryDelay:   3 * time.Second,
			RetryCount:   2,
		},
	}

	// 添加任务
	scheduler.AddTasks(tasks...)

	// 启动调度
	if err := scheduler.Start(); err != nil {
		log.Fatal(&quot;启动失败:&quot;, err)
	}

	// 等待所有任务完成
	fmt.Println(&quot;调度器运行中, Ctrl+C 停止&quot;)

	// 监听中断信号
	sigChan := make(chan os.Signal, 1)
	signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)

	// 等待完成或者收到中断信号
	ticker := time.NewTicker(time.Second)
	defer ticker.Stop()

	for {
		select {
		case &amp;#x3C;-sigChan:
			fmt.Println(&quot;\n接收到中断信号，正在停止...&quot;)
			scheduler.Stop()
			scheduler.PrintSummary()
			return
		case &amp;#x3C;-ticker.C:
			// 检查是否所有任务都已完成
			results := scheduler.GetResults()
			if len(results) == len(tasks) {
				scheduler.Stop()
				scheduler.PrintSummary()
				return
			}
		}
	}
}

&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;77. Worker Pool 的设计与实现&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;当前问题存在示例代码，可以前往&lt;a href=&quot;https://github.com/zxc7563598/go-lab/commit/32525dde65e1b2e684ab75e0c5fe25df1c48aa57&quot;&gt;GitHub查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;一开始接触 Go 并发的时候，很容易写出这样的代码：来一个任务，就起一个 goroutine。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;go doWork(job)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这种写法在“任务不多、生命周期很短”的时候非常顺滑，也很符合直觉。&lt;/p&gt;
&lt;p&gt;但当我把这个模式往真实场景里套时，很快就会意识到一个问题：&lt;strong&gt;goroutine 虽然便宜，但不是无限的&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;当任务数量不受控、或者外部输入突增时，“来一个起一个”本质上是在把并发压力直接暴露给运行时。&lt;/p&gt;
&lt;p&gt;这时候我才意识到，worker pool 并不是为了“提高并发”，而是为了​&lt;strong&gt;限制并发&lt;/strong&gt;。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;从设计意图上看，worker pool 做的事情其实很简单：把「任务的产生」和「任务的执行」拆开。&lt;/p&gt;
&lt;p&gt;任务可以源源不断地产生，但真正执行任务的 goroutine 数量是固定的。&lt;/p&gt;
&lt;p&gt;在 Go 里，这个拆分几乎天然就会落到 channel 上。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;type Job struct {
	ID int
}

func worker(id int, jobs &amp;#x3C;-chan Job) {
	for job := range jobs {
		fmt.Printf(&quot;[worker %d] 开始处理任务 %d\n&quot;, id, job.ID)
	}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的 worker 非常“老实”：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;不创建 goroutine&lt;/li&gt;
&lt;li&gt;不关心任务从哪里来&lt;/li&gt;
&lt;li&gt;只做一件事：从 channel 里拿任务，处理，然后继续等下一个&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这和 PHP 世界里常见的“一个请求进来 → 一条执行路径跑到底”是完全不同的感觉。&lt;/p&gt;
&lt;p&gt;这里更像是：&lt;strong&gt;程序结构先被稳定下来，数据在结构里流动&lt;/strong&gt;。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;接下来是 pool 本身，也就是“开多少个 worker”。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func startWorkerPool(workerNum int, jobs &amp;#x3C;-chan Job) {
	for i := 0; i &amp;#x3C; workerNum; i++ {
		go worker(i, jobs)
	}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个地方让我第一次意识到 Go 并发的一个特点：&lt;strong&gt;goroutine 的创建是集中发生的，而不是分散在业务逻辑里&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;worker 的数量在这里就已经定死了，后面的代码即使疯狂往 jobs 里塞任务，也只会有这几个 goroutine 在干活。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;任务的产生端反而变得非常“普通”。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func main() {
	jobs := make(chan Job)

	startWorkerPool(3, jobs)

	for i := 0; i &amp;#x3C; 10; i++ {
		jobs &amp;#x3C;- Job{ID: i}
	}

	close(jobs)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果你站在 PHP 的视角看这段代码，会发现一个很有意思的变化：&lt;strong&gt;主 goroutine 不负责干活，它只是把任务“投递”出去&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;这里没有锁，没有共享状态，甚至没有显式的并发控制语句。&lt;/p&gt;
&lt;p&gt;并发被“压缩”进了 channel 的语义里。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;在我理解 worker pool 的过程中，一个比较重要的转折点是：我开始把它当成一种&lt;strong&gt;结构设计&lt;/strong&gt;，而不是并发技巧。&lt;/p&gt;
&lt;p&gt;worker pool 本身并不聪明：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;不保证任务顺序&lt;/li&gt;
&lt;li&gt;不保证任务成功&lt;/li&gt;
&lt;li&gt;不关心任务失败如何处理&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;它唯一保证的是：同一时间，最多只有 N 个任务在被执行。&lt;/p&gt;
&lt;p&gt;这让我意识到一个事实：&lt;strong&gt;worker pool 管的是“并发度”，不是“业务逻辑”&lt;/strong&gt; 。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;当你开始往真实系统靠的时候，worker pool 往往会自然地多长出一些东西。&lt;/p&gt;
&lt;p&gt;比如，增加退出控制：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func worker(ctx context.Context, id int, jobs &amp;#x3C;-chan Job) {
	for {
		select {
		case &amp;#x3C;-ctx.Done():
			return
		case job, ok := &amp;#x3C;-jobs:
			if !ok {
				return
			}
			fmt.Printf(&quot;worker %d handling job %d\n&quot;, id, job.ID)
		}
	}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这时候你会发现，worker pool 和「优雅关闭」「服务生命周期」已经开始产生联系了。&lt;/p&gt;
&lt;p&gt;它不再只是一个并发工具，而是服务结构的一部分。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;回头看，worker pool 对我来说最大的价值，不是“学会了一种并发模式”，而是让我开始接受这样一种思路：不要让并发随意生长，先设计结构，再让任务流经结构&lt;/p&gt;
&lt;p&gt;这可能也是 Go 和 PHP 在并发模型上给我最大的心理差异。&lt;/p&gt;
&lt;p&gt;PHP 更像是“请求驱动代码执行”，而 Go 更像是“结构驱动任务流动”。&lt;/p&gt;
&lt;p&gt;worker pool，只是这个思路里一个非常早、也非常典型的例子。&lt;/p&gt;
&lt;h3&gt;78. 限流、超时与失败控制&lt;/h3&gt;
&lt;p&gt;刚开始从 PHP 转到 Go，看「限流、超时、失败控制」这些词，会下意识把它们当成“框架能力”或者“中间件功能”。&lt;/p&gt;
&lt;p&gt;在 PHP 世界里，很多时候确实是这样：Nginx、FPM、框架、网关已经帮你做掉了，你只是在配置层面“启用”。&lt;/p&gt;
&lt;p&gt;但在 Go 里，我慢慢意识到，这三件事更像是​&lt;strong&gt;代码层面的时间与容量意识&lt;/strong&gt;，而不是某个现成的功能开关。&lt;/p&gt;
&lt;p&gt;限流，本质上是在承认一件事：&lt;strong&gt;系统不是无限的&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;不是“防止被打爆”，而是你需要明确告诉自己：我现在愿意同时处理多少件事。&lt;/p&gt;
&lt;p&gt;在 Go 里，这种意识很自然地会落到 channel 上。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;var limiter = make(chan struct{}, 10) // 同时最多处理 10 个请求

func handle(req int) {
    limiter &amp;#x3C;- struct{}{}        // 进入限流区
    defer func() { &amp;#x3C;-limiter }() // 处理完成后释放

    // 模拟业务处理
    time.Sleep(200 * time.Millisecond)
    fmt.Println(&quot;handled&quot;, req)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个写法一开始看着有点“原始”，但用久了会发现它非常直观：channel 的容量就是你对系统承载能力的一个&lt;strong&gt;明确表态&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;和 PHP 不同的是，这里不是等队列无限堆积，而是你在代码里清楚地画了一条线：“超过这个数量，我宁可等，也不继续往里塞”。&lt;/p&gt;
&lt;p&gt;接着是超时。&lt;/p&gt;
&lt;p&gt;超时在 Go 里不是一个“异常情况”，而是一种&lt;strong&gt;默认应该存在的边界&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;这点和 PHP 的感受差异很大。PHP 更多是：“这个请求慢了，那就慢了，反正进程也快结束了。”&lt;/p&gt;
&lt;p&gt;而 Go 常驻进程的视角是：“如果我不主动设定时间，慢就会变成常态。”&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
defer cancel()

err := doSomething(ctx)
if err != nil {
    fmt.Println(&quot;failed:&quot;, err)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;一开始我会纠结：“这个 300ms 是不是太武断了？”&lt;/p&gt;
&lt;p&gt;后来想通了，它并不是一个精确的承诺，而是一个&lt;strong&gt;态度&lt;/strong&gt;：超过这个时间，我不再认为这件事值得继续消耗资源。&lt;/p&gt;
&lt;p&gt;而 context 的设计让这件事不是“强行中断”，而是&lt;strong&gt;层层传递的放弃信号&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;谁最先意识到“不值得继续”，谁就停下来。&lt;/p&gt;
&lt;p&gt;失败控制是这三者里最容易被误解的。&lt;/p&gt;
&lt;p&gt;很多时候我们会把失败当成“异常路径”，但在 Go 的世界里，失败反而更像是一种&lt;strong&gt;常规结果&lt;/strong&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;if err != nil {
    return err
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;写多了会发现，这不是消极，而是非常诚实。&lt;/p&gt;
&lt;p&gt;Go 不鼓励你“兜底一切”，而是逼你在每一层都想清楚：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;这一步失败了，还要不要继续？&lt;/li&gt;
&lt;li&gt;是立即返回，还是重试？&lt;/li&gt;
&lt;li&gt;重试几次算合理？&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;for i := 0; i &amp;#x3C; 3; i++ {
    err := doSomething()
    if err == nil {
        return nil
    }
    time.Sleep(100 * time.Millisecond)
}
return errors.New(&quot;retry failed&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里没有什么“高级策略”，但它让失败变成了​&lt;strong&gt;一个被认真对待的路径&lt;/strong&gt;，而不是日志里的一行抱怨。&lt;/p&gt;
&lt;p&gt;把这三件事放在一起看，我慢慢发现它们其实在解决同一件事：&lt;strong&gt;如何在不确定的世界里，给系统设定清晰的边界&lt;/strong&gt;。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;限流：我一次只接这么多&lt;/li&gt;
&lt;li&gt;超时：我只等这么久&lt;/li&gt;
&lt;li&gt;失败控制：我只尝试到这个程度&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;它们并不会让系统“更强”，但会让系统&lt;strong&gt;更可控&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;而这恰恰是从 PHP 过来后，我对 Go 最明显的一次理解变化：不是“能不能跑”，而是“什么时候该停”。&lt;/p&gt;
&lt;h3&gt;79. 从“能跑”到“稳定”的改造过程&lt;/h3&gt;
&lt;p&gt;一开始的“能跑”，通常就是一个最朴素的 HTTP 服务。&lt;/p&gt;
&lt;p&gt;它没有任何边界意识，但逻辑是完整闭合的。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;package main

import (
	&quot;fmt&quot;
	&quot;net/http&quot;
	&quot;time&quot;
)

func main() {
	http.HandleFunc(&quot;/work&quot;, func(w http.ResponseWriter, r *http.Request) {
		// 模拟一个耗时操作
		time.Sleep(800 * time.Millisecond)
		fmt.Fprintln(w, &quot;ok&quot;)
	})

	fmt.Println(&quot;server start at :8080&quot;)
	http.ListenAndServe(&quot;:8080&quot;, nil)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个程序非常“干净”：请求来了就做事，做完就返回。&lt;/p&gt;
&lt;p&gt;慢，也只是慢而已。&lt;/p&gt;
&lt;p&gt;问题在于：&lt;strong&gt;你不知道慢到什么时候算不合理&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;于是第一次改造，往往是给请求加上时间边界。&lt;/p&gt;
&lt;p&gt;这一步并不是为了优化性能，而是为了让“放弃”变得可描述。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;package main

import (
	&quot;context&quot;
	&quot;fmt&quot;
	&quot;net/http&quot;
	&quot;time&quot;
)

func main() {
	http.HandleFunc(&quot;/work&quot;, func(w http.ResponseWriter, r *http.Request) {
		ctx, cancel := context.WithTimeout(r.Context(), 500*time.Millisecond)
		defer cancel()

		err := doWork(ctx)
		if err != nil {
			http.Error(w, err.Error(), http.StatusGatewayTimeout)
			return
		}

		fmt.Fprintln(w, &quot;ok&quot;)
	})

	fmt.Println(&quot;server start at :8080&quot;)
	http.ListenAndServe(&quot;:8080&quot;, nil)
}

func doWork(ctx context.Context) error {
	select {
	case &amp;#x3C;-time.After(800 * time.Millisecond):
		return nil
	case &amp;#x3C;-ctx.Done():
		return ctx.Err()
	}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;现在这个服务仍然“能跑”，但已经多了一层态度：&lt;strong&gt;超过 500ms，这件事就不再值得继续&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;接下来，当并发请求上来时，你会发现另一个问题：就算每个请求都有超时，&lt;strong&gt;同时跑太多也会把自己拖死&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;这时候改造点通常落在并发数量上。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;package main

import (
	&quot;context&quot;
	&quot;fmt&quot;
	&quot;net/http&quot;
	&quot;time&quot;
)

var limiter = make(chan struct{}, 5) // 同时最多 5 个请求在处理

func main() {
	http.HandleFunc(&quot;/work&quot;, func(w http.ResponseWriter, r *http.Request) {
		select {
		case limiter &amp;#x3C;- struct{}{}:
			defer func() { &amp;#x3C;-limiter }()
		default:
			http.Error(w, &quot;too many requests&quot;, http.StatusTooManyRequests)
			return
		}

		ctx, cancel := context.WithTimeout(r.Context(), 500*time.Millisecond)
		defer cancel()

		err := doWork(ctx)
		if err != nil {
			http.Error(w, err.Error(), http.StatusGatewayTimeout)
			return
		}

		fmt.Fprintln(w, &quot;ok&quot;)
	})

	fmt.Println(&quot;server start at :8080&quot;)
	http.ListenAndServe(&quot;:8080&quot;, nil)
}

func doWork(ctx context.Context) error {
	select {
	case &amp;#x3C;-time.After(800 * time.Millisecond):
		return nil
	case &amp;#x3C;-ctx.Done():
		return ctx.Err()
	}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里有一个非常典型的“稳定性取舍”：当系统已经忙不过来了，&lt;strong&gt;直接拒绝新请求&lt;/strong&gt;，而不是让所有请求一起慢。&lt;/p&gt;
&lt;p&gt;这在“能跑”的阶段通常很难下这个决定，但在“稳定”的视角里，这是在保护已经进来的请求。&lt;/p&gt;
&lt;p&gt;最后一个常见变化，是你不再指望“一次就成功”。&lt;/p&gt;
&lt;p&gt;失败不再只是返回错误，而是变成一个可控的过程。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;package main

import (
	&quot;context&quot;
	&quot;errors&quot;
	&quot;fmt&quot;
	&quot;net/http&quot;
	&quot;time&quot;
)

var limiter = make(chan struct{}, 5)

func main() {
	http.HandleFunc(&quot;/work&quot;, func(w http.ResponseWriter, r *http.Request) {
		select {
		case limiter &amp;#x3C;- struct{}{}:
			defer func() { &amp;#x3C;-limiter }()
		default:
			http.Error(w, &quot;too many requests&quot;, http.StatusTooManyRequests)
			return
		}

		ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second)
		defer cancel()

		err := retry(ctx, 3, func() error {
			return doWork(ctx)
		})
		if err != nil {
			http.Error(w, err.Error(), http.StatusBadGateway)
			return
		}

		fmt.Fprintln(w, &quot;ok&quot;)
	})

	fmt.Println(&quot;server start at :8080&quot;)
	http.ListenAndServe(&quot;:8080&quot;, nil)
}

func doWork(ctx context.Context) error {
	select {
	case &amp;#x3C;-time.After(400 * time.Millisecond):
		// 模拟偶发失败
		return errors.New(&quot;random failure&quot;)
	case &amp;#x3C;-ctx.Done():
		return ctx.Err()
	}
}

func retry(ctx context.Context, times int, fn func() error) error {
	var err error
	for i := 0; i &amp;#x3C; times; i++ {
		if err = fn(); err == nil {
			return nil
		}
		select {
		case &amp;#x3C;-time.After(100 * time.Millisecond):
		case &amp;#x3C;-ctx.Done():
			return ctx.Err()
		}
	}
	return err
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;到这里，这个服务并没有“更聪明”，&lt;/p&gt;
&lt;p&gt;但它已经具备了一种&lt;strong&gt;稳定时的自我克制&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;不无限接请求&lt;/li&gt;
&lt;li&gt;不无限等待&lt;/li&gt;
&lt;li&gt;不无限重试&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;回头看，“能跑”到“稳定”的改造过程，并不是一步到位的架构升级，而是一点点把&lt;strong&gt;隐含假设变成显式边界&lt;/strong&gt;的过程。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;十八、性能与运行时感知&lt;/h2&gt;
&lt;h3&gt;80. Go GC 的基本行为&lt;/h3&gt;
&lt;p&gt;刚接触 Go 的时候，我对 GC 的感知几乎是“它存在，但我不用管”。&lt;/p&gt;
&lt;p&gt;这种感觉和写 PHP 时其实很像：脚本结束，内存自然就回收了，至于中间发生了什么，并不会成为心智负担。&lt;/p&gt;
&lt;p&gt;直到我开始写​&lt;strong&gt;常驻进程&lt;/strong&gt;，这种“无感”才第一次被打破。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;package main

func main() {
	for {
		data := make([]byte, 1024*1024)
		_ = data
	}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这段代码什么都不做，只是不断分配 1MB 的内存。&lt;/p&gt;
&lt;p&gt;它能跑，而且跑得“看起来没问题”，CPU 不高，程序也没崩。&lt;/p&gt;
&lt;p&gt;但这里其实已经触发了 Go GC 的一个最基本行为：&lt;strong&gt;GC 并不是在内存“用完”时才发生，而是在分配过程中被持续触发的。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;在 Go 里，内存分配本身就是 GC 的信号源之一。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;进一步观察时，我开始意识到一个和直觉不太一样的点：&lt;strong&gt;GC 并不是“停下来一次性清干净”。&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;package main

import (
	&quot;fmt&quot;
	&quot;time&quot;
)

func main() {
	go func() {
		for {
			_ = make([]byte, 1024*1024)
			time.Sleep(10 * time.Millisecond)
		}
	}()

	for {
		fmt.Println(&quot;running&quot;)
		time.Sleep(100 * time.Millisecond)
	}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;程序一边分配内存，一边持续输出日志。&lt;/p&gt;
&lt;p&gt;如果 GC 是那种“全停”的行为，那么理论上我应该能看到明显的卡顿。&lt;/p&gt;
&lt;p&gt;但实际感受是：&lt;strong&gt;输出节奏基本稳定，没有那种“突然停一下”的感觉。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这是我第一次真正意识到：Go 的 GC 设计目标，从一开始就不是“回收得最快”，而是“别太打扰程序运行”。&lt;/p&gt;
&lt;p&gt;它是​&lt;strong&gt;并发的、增量的&lt;/strong&gt;，而不是一个集中爆发的清扫动作。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;不过，“不太打扰”并不等于“完全没有代价”。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;package main

func alloc() []byte {
	return make([]byte, 1024)
}

func main() {
	for i := 0; i &amp;#x3C; 1_000_000; i++ {
		_ = alloc()
	}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这类代码在功能上毫无问题，但它揭示了 GC 的另一个基本事实：&lt;strong&gt;GC 的成本，和“活跃对象的数量”强相关。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;不是分配得多就一定慢，而是&lt;strong&gt;分配之后还活着的对象越多，GC 越辛苦。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这也解释了一个常见但容易被误解的现象：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;有些程序分配频繁，但 GC 压力不大&lt;/li&gt;
&lt;li&gt;有些程序分配并不多，但一到 GC 就抖一下&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;问题往往不在“分配了多少”，而在“留住了多少”。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;从 PHP 的视角来看，这一点非常不直观。&lt;/p&gt;
&lt;p&gt;在 PHP 里，请求结束就是天然的“内存清零点”；&lt;/p&gt;
&lt;p&gt;而在 Go 里，&lt;strong&gt;程序没有“请求结束”这个时间点，只有对象是否仍然可达&lt;/strong&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;type Cache struct {
	data []byte
}

func main() {
	c := &amp;#x26;Cache{
		data: make([]byte, 1024*1024*100),
	}
	_ = c
	select {}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里没有循环，也没有持续分配。&lt;/p&gt;
&lt;p&gt;但这 100MB 内存会一直被认为是“活的”。&lt;/p&gt;
&lt;p&gt;GC 并不会因为“它好久没被用过”而回收它，&lt;/p&gt;
&lt;p&gt;只要引用关系还在，它就仍然属于运行时的一部分。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;所以在我理解里，Go GC 的“基本行为”其实可以浓缩成几句话：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;GC 是​&lt;strong&gt;持续发生的背景活动&lt;/strong&gt;，不是阶段性的大扫除&lt;/li&gt;
&lt;li&gt;它更在意​&lt;strong&gt;程序的平稳运行&lt;/strong&gt;，而不是极限吞吐&lt;/li&gt;
&lt;li&gt;回收压力的核心，不是分配频率，而是&lt;strong&gt;存活对象规模&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;在常驻服务中，&lt;strong&gt;对象的生命周期设计本身，就是性能设计的一部分&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;也正是从这里开始，我才意识到：性能问题并不是“写慢代码”，而是&lt;strong&gt;在不知不觉中，和运行时站到了对立面&lt;/strong&gt;。&lt;/p&gt;
&lt;h3&gt;81. 什么时候需要关注内存分配&lt;/h3&gt;
&lt;p&gt;一开始写 Go 的时候，我几乎不会主动去想“这行代码分配了多少内存”。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func handle() {
	data := make([]int, 0)
	for i := 0; i &amp;#x3C; 1000; i++ {
		data = append(data, i)
	}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这段代码非常自然，也完全没问题。&lt;/p&gt;
&lt;p&gt;如果这是一个偶尔调用的函数，或者只是启动时跑一次，那它的内存分配几乎可以忽略。&lt;/p&gt;
&lt;p&gt;所以我慢慢意识到一个前提：&lt;strong&gt;只有在“被频繁执行”的路径上，内存分配才开始有意义。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;不是所有代码都值得被当成性能代码来看。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;真正让我开始关注分配的，往往是这种场景：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func handler(w http.ResponseWriter, r *http.Request) {
	buf := make([]byte, 4096)
	_, _ = r.Body.Read(buf)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;单次看，这点内存微不足道。&lt;/p&gt;
&lt;p&gt;但当它变成一个 QPS 很高的 HTTP 接口时，事情就变了。&lt;/p&gt;
&lt;p&gt;这里并不是说 “4096 字节很大”，而是&lt;strong&gt;这个分配，会在每一次请求中发生&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;当我把注意力从“这次分配多不多”转移到“这行代码会被跑多少次”时，视角就完全变了。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;还有一种情况，是代码结构让我开始警觉的。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func buildResult(items []Item) []Result {
	var results []Result
	for _, item := range items {
		results = append(results, Result{
			ID:   item.ID,
			Name: item.Name,
		})
	}
	return results
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;它逻辑清晰、可读性也不错。&lt;/p&gt;
&lt;p&gt;但如果这个函数处在一个&lt;strong&gt;嵌套循环&lt;/strong&gt;里，或者是&lt;strong&gt;链路中的中间层&lt;/strong&gt;，&lt;/p&gt;
&lt;p&gt;那么这里的 slice 扩容、对象构造，就会被放大很多倍。&lt;/p&gt;
&lt;p&gt;这时我开始学会问自己一个问题：这个分配，是不是“每一层都在默默做一遍”？&lt;/p&gt;
&lt;p&gt;当分配隐藏在“看起来很干净的抽象”里时，它往往最容易被忽略。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;和 PHP 对比时，这种感觉尤其明显。&lt;/p&gt;
&lt;p&gt;在 PHP 里，很多临时变量本来就是“请求级”的；&lt;/p&gt;
&lt;p&gt;它们会在请求结束时被统一释放，开发者很少去想生命周期。&lt;/p&gt;
&lt;p&gt;但在 Go 里，​&lt;strong&gt;请求只是业务概念，不是内存边界&lt;/strong&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;type Service struct {
	tmp []byte
}

func (s *Service) Handle() {
	s.tmp = make([]byte, 1024)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这段代码在功能上没问题，但它让一次“本该是临时的分配”，变成了一个&lt;strong&gt;长期存活的对象&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;这类情况往往不会在功能测试里暴露，而是在服务跑了一段时间后，才慢慢体现在内存占用和 GC 行为上。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;还有一个我后来才意识到的信号：&lt;strong&gt;当我开始觉得 GC “有点频繁”时，问题往往已经不是 GC 本身了。&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;for {
	data := make([]byte, 1024)
	process(data)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;GC 频繁，通常只是结果。&lt;/p&gt;
&lt;p&gt;真正的原因，往往是这类代码出现在了一个我没意识到的热点路径里。&lt;/p&gt;
&lt;p&gt;也正因为如此，我逐渐形成了一个比较保守的判断标准：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;冷路径上的分配，可以先忽略&lt;/li&gt;
&lt;li&gt;启动阶段的分配，通常不重要&lt;/li&gt;
&lt;li&gt;请求级、循环内、并发路径上的分配，值得多看一眼&lt;/li&gt;
&lt;li&gt;一旦和 GC 行为产生“体感关联”，就该认真对待了&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;p&gt;所以对我来说，“关注内存分配”并不是一件一开始就要做的事。&lt;/p&gt;
&lt;p&gt;它更像是一个阶段性的意识变化：&lt;strong&gt;当程序开始长时间运行、并且有了稳定负载之后，内存分配就不再只是实现细节，而开始参与性能结果。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;82. pprof 的基础使用&lt;/h3&gt;
&lt;p&gt;一开始产生“要不要做性能分析”的念头，其实非常模糊。&lt;/p&gt;
&lt;p&gt;程序能跑，功能也对，只是偶尔会冒出一种不太确定的感觉：&lt;strong&gt;我不知道它现在跑得算不算健康。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;不是慢到出问题的那种，而是如果哪天负载上来，我完全没有判断依据。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;在这种状态下，“性能分析”这个词本身就显得很抽象。&lt;/p&gt;
&lt;p&gt;我并不是要做极致优化，而只是想回答一些很基础的问题：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;CPU 时间大概花在了哪里&lt;/li&gt;
&lt;li&gt;内存是不是在持续增长&lt;/li&gt;
&lt;li&gt;GC 是否在我没注意到的时候频繁发生&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;直到这时，我才第一次意识到：&lt;strong&gt;这些问题，靠读代码是回答不了的。&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;pprof 就是在这个背景下进入视野的。&lt;/p&gt;
&lt;p&gt;它并不是一个额外安装的工具，而是 &lt;strong&gt;Go 运行时自带的一套 profiling 能力&lt;/strong&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;import _ &quot;net/http/pprof&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;第一次看到这行代码的时候，我的反应其实有点意外：&lt;/p&gt;
&lt;p&gt;原来只需要一个空导入，就可以把整个运行时暴露出来。&lt;/p&gt;
&lt;p&gt;这也让我对 pprof 的定位变得清晰起来：&lt;strong&gt;它不是调试器，也不是监控系统，而是运行时的“侧写”。&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;要使用 pprof，最基础的一步其实很简单：&lt;strong&gt;给程序留一个可以被观察的入口。&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;package main

import (
	&quot;log&quot;
	&quot;net/http&quot;
	_ &quot;net/http/pprof&quot;
)

func main() {
	go func() {
		log.Println(http.ListenAndServe(&quot;:6060&quot;, nil))
	}()

	select {}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这段代码并没有改变程序的业务逻辑，它只是额外开启了一个 HTTP 服务，用来暴露 profiling 数据。&lt;/p&gt;
&lt;p&gt;当我第一次访问 &lt;code&gt;/debug/pprof/&lt;/code&gt;​ 时，才真正意识到：&lt;strong&gt;pprof 不是一次性的分析，而是一扇随时可以打开的窗口。&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;接下来，才轮到“怎么用”。&lt;/p&gt;
&lt;p&gt;pprof 并不是在浏览器里点点看看的工具，真正的使用方式，还是通过命令行。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;go tool pprof http://localhost:6060/debug/pprof/heap
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这一刻，我并没有指望它给我答案。&lt;/p&gt;
&lt;p&gt;我只是想知道：如果我真的要看内存，现在应该从哪里开始？&lt;/p&gt;
&lt;p&gt;heap profile 给出的，正是这样一个起点：&lt;strong&gt;当前进程里，内存主要花在了哪些地方。&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;CPU profiling 的使用方式也类似，只是多了一个“时间窗口”。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的 30 秒，并不是为了“抓住问题”，&lt;/p&gt;
&lt;p&gt;而是为了让我建立一个概念：&lt;strong&gt;性能不是某一瞬间的状态，而是一段时间内的分布。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这和我之前那种“打日志、凭感觉判断快慢”的方式，完全不同。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;慢慢地，我才意识到 pprof 的使用门槛其实并不高，真正的难点不在“怎么跑命令”，而在&lt;strong&gt;怎么看结果&lt;/strong&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;(pprof) top
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个命令非常朴素，却是我最常用的入口。&lt;/p&gt;
&lt;p&gt;它不会试图解释因果，只是把消耗排好顺序。&lt;/p&gt;
&lt;p&gt;而排序，本身就已经是一种过滤。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;所以回过头看，我对 pprof 的理解经历了一个很清晰的变化过程：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;一开始只是模糊地想“要不要做性能分析”&lt;/li&gt;
&lt;li&gt;然后发现代码本身给不了我答案&lt;/li&gt;
&lt;li&gt;接着了解到 Go 自带 pprof 这样的运行时工具&lt;/li&gt;
&lt;li&gt;最后学会用它去确认，而不是猜测&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;pprof 并没有让我立刻写出更快的代码，但它让我第一次有了一种感觉：&lt;strong&gt;我不是在对着黑盒调性能，而是在观察一个正在运行的系统。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;83. 常见性能误区（过度并发、过度抽象）&lt;/h3&gt;
&lt;p&gt;刚开始写 Go 的时候，我对并发几乎是天然信任的。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;for _, item := range items {
	go process(item)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这段代码看起来就像是在“正确使用 Go”。&lt;/p&gt;
&lt;p&gt;goroutine 很轻，语法也简单，不用就有点浪费。&lt;/p&gt;
&lt;p&gt;但真正让我开始怀疑这种写法的，并不是性能问题，而是&lt;strong&gt;运行一段时间后的不确定性&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;CPU 偶尔飙高，内存曲线变得难以解释，而我却很难用代码结构去回答一个问题：现在，到底有多少事情在同时发生？&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;过度并发的第一个误区，往往不是“太慢”，而是&lt;strong&gt;失去了对并发规模的感知&lt;/strong&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func handle(items []Item) {
	for _, item := range items {
		go func(it Item) {
			process(it)
		}(item)
	}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这类代码在小数据量下表现得非常“健康”，但一旦输入规模变化，goroutine 的数量就会线性放大。&lt;/p&gt;
&lt;p&gt;问题不在于 goroutine 本身，而在于：&lt;strong&gt;它们的创建，几乎没有成本感知&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;当我开始用 pprof 或 runtime 指标观察程序时，才意识到很多性能问题，其实是&lt;strong&gt;并发失控的副作用&lt;/strong&gt;。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;另一个容易被忽略的点是：&lt;strong&gt;并发并不等于并行，更不等于更快。&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;for i := 0; i &amp;#x3C; 100; i++ {
	go work()
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果 &lt;code&gt;work&lt;/code&gt; 本身是 I/O 阻塞的，这样做可能是对的；&lt;/p&gt;
&lt;p&gt;但如果它主要是 CPU 计算，那我只是让调度器更忙而已。&lt;/p&gt;
&lt;p&gt;这时性能问题往往表现为：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;CPU 利用率很高&lt;/li&gt;
&lt;li&gt;吞吐却没有明显提升&lt;/li&gt;
&lt;li&gt;延迟反而变得不稳定&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;并发在这里，已经从“工具”变成了“噪音”。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;相比并发，​&lt;strong&gt;过度抽象的误区更隐蔽一些&lt;/strong&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;type Processor interface {
	Process([]byte) []byte
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;接口本身没有错，&lt;/p&gt;
&lt;p&gt;问题在于它常常被放在&lt;strong&gt;调用频率极高的路径上&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;当我把一段逻辑拆成很多“小而优雅”的组件时，代码看起来更清爽了，但运行时却多了层层间接调用、对象构造和逃逸风险。&lt;/p&gt;
&lt;p&gt;这些开销单独看都很小，但在热点路径上，会被无限放大。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;在 PHP 里，这种抽象成本往往被请求生命周期“抹平”；&lt;/p&gt;
&lt;p&gt;而在 Go 里，它们会真实地体现在 CPU 和内存曲线上。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func handle(p Processor, data []byte) []byte {
	return p.Process(data)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;当我在 pprof 里看到这些“看起来很干净的代码”&lt;/p&gt;
&lt;p&gt;出现在 top 列表中时，才意识到一个事实：&lt;strong&gt;抽象并不是免费的，只是它的成本往往不在我写代码的时候出现。&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;后来我慢慢形成了一些很朴素的自我约束：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;并发之前，先想清楚“规模”&lt;/li&gt;
&lt;li&gt;抽象之前，确认它是否在热点路径&lt;/li&gt;
&lt;li&gt;不要为了“Go 风格”，去强行并发或拆层&lt;/li&gt;
&lt;li&gt;当性能成为问题时，优先怀疑结构，而不是语法&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;性能误区的可怕之处，不在于写错代码，而在于：&lt;strong&gt;它们通常来自“看起来很对的选择”。&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;所以在这个章节的结尾，我对“性能与运行时感知”的理解，其实变得很简单：&lt;/p&gt;
&lt;p&gt;不是学会了多少优化技巧，而是开始意识到：&lt;strong&gt;每一个看似优雅的设计决定，都会在运行时留下痕迹。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;当我愿意去看这些痕迹、理解它们，性能就不再是一件靠运气的事情了。&lt;/p&gt;</content:encoded><h:img src="/_astro/go.aBeHx0xJ.jpg"/><enclosure url="/_astro/go.aBeHx0xJ.jpg"/></item><item><title>2025 年度复盘：一些工作之外的项目、工具和想法</title><link>https://hejunjie.life/blog/9du5hgn6</link><guid isPermaLink="true">https://hejunjie.life/blog/9du5hgn6</guid><description>这一年里，因为工作节奏的变化，我有了更多属于自己的时间。这篇复盘记录了我在工作之外做过的一些项目、工具与尝试，以及过程中对技术、开源和长期维护的重新认识。</description><pubDate>Thu, 01 Jan 2026 04:26:24 GMT</pubDate><content:encoded>&lt;p&gt;2025 年对我来说，是一个节奏明显不一样的年份。&lt;/p&gt;
&lt;p&gt;工作本身发生了一些变化，一方面是内容上的调整，另一方面也因为单位的特殊性，涉及比较高的保密要求，很多事情并不适合写出来。再加上整体节奏不像去年那样紧绷，我不再每天十几个小时泡在公司里，于是突然多出了一部分可以自由支配的时间。&lt;/p&gt;
&lt;p&gt;这些时间我几乎没有拿去“休息”，而是顺手用来做了一些事情。
它们大多发生在工作之外，也基本不和工作直接相关。&lt;/p&gt;
&lt;p&gt;之所以会这么做，其实和我的一些想法有关。&lt;/p&gt;
&lt;p&gt;我并不是什么大厂背景的技术人，也没有年薪几十、上百万的预期。对我来说，世界并不只是由头部互联网公司组成的，更多的是数量庞大的普通公司、民营企业，而它们中的大多数，甚至还没真正走到“追逐前沿技术”的阶段。很多时候，连负载均衡这种事情，都是相当遥远的。&lt;/p&gt;
&lt;p&gt;也正因为如此，我对所谓的年龄焦虑、技术斩杀线之类的说法，并没有太强的感受。&lt;/p&gt;
&lt;p&gt;在我看来，去掉互联网的泡沫之后，只要我能够持续创造出有价值的东西，就一定能拿到与之相匹配的回报。只不过在泡沫之中，这种“匹配价值的回报”，往往会显得不那么诱人，甚至远低于某些人的心理预期。&lt;/p&gt;
&lt;p&gt;但时代的浪潮并不是靠个人意志就能左右的。正是因为太多人执着于攀登那些遥不可及的浪尖，才会在浪潮退去的时候，来不及反应，被拍得措手不及。&lt;/p&gt;
&lt;p&gt;这当然只是我个人的理解，并不是什么结论，也谈不上评判。
只是基于这样的想法，比起焦虑那些还没真正落地的技术，或者遥遥无期的方案，我更愿意把时间花在一件事上：&lt;strong&gt;尽量稳固自己的基本盘&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;至少让我自己，始终是一个在未来还能持续创造价值的人。&lt;/p&gt;
&lt;p&gt;这篇文章想记录的，就是在这样的心态下，这一年里，我在这些空出来的时间里，都折腾了些什么。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;一月-从一个自用项目开始的祛魅&lt;/h2&gt;
&lt;p&gt;2025 年的开头，我把之前写的积分商城和弹幕机器人整理了一下，正式开源，并补上了 Docker 部署方案。&lt;/p&gt;
&lt;p&gt;这算是我比较完整地、从实战角度重新审视 Docker 的一次机会。结论其实挺简单的：&lt;/p&gt;
&lt;p&gt;如果真的涉及多实例、复杂部署、负载均衡，那 Docker 确实很好用；
但如果只是一个一台服务器就能跑得很舒服的小项目，它也没有被说得那么“神”。&lt;/p&gt;
&lt;p&gt;对我来说，这更像是一次技术祛魅。
不是说 Docker 不重要，而是它并不需要出现在每一个项目里。&lt;/p&gt;
&lt;p&gt;这个月还顺手给机器人补了禁言一类的基础能力，整体还是“我自己用得顺就行”的阶段。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;二月-开始意识到有人在用&lt;/h2&gt;
&lt;p&gt;二月做的事情不算多，但对我来说很重要。&lt;/p&gt;
&lt;p&gt;我开始收到一些使用者的反馈，于是给积分商城和弹幕机器人补了两个看起来很小的功能：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;兑换成功后的邮件通知&lt;/li&gt;
&lt;li&gt;虚拟商品支持只填写邮箱，不再强制填地址&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这些功能本身没什么技术含量，但它们让我第一次清楚地意识到一件事：&lt;br&gt;
&lt;strong&gt;这个东西，已经不是只给我自己用的了。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;从这个时候开始，我对这个项目的态度发生了一点变化。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;三月-开始有意识地把东西封装出来&lt;/h2&gt;
&lt;p&gt;三月是一个很明显的转折点。&lt;/p&gt;
&lt;p&gt;一方面，我帮朋友做了一个记录奶茶消费的小程序。
虽然这个事情最后并没有继续推进，但让我完整地重温了一次微信小程序的开发流程，也顺手写了一篇博客记录。&lt;/p&gt;
&lt;p&gt;另一方面，我开始认真思考一件以前其实没怎么想过的事：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;工作中反复遇到的这些东西，能不能少写几遍？&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;于是这个月我做了不少偏“基础建设”的事情：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;整理了一套 webman + vue3 + naiveui 的后台脚手架&lt;/li&gt;
&lt;li&gt;用 Go 写了一个很简单的错误日志收集小站，顺便熟悉 Go&lt;/li&gt;
&lt;li&gt;把公司里一直在用的 MySQL 备份脚本整理完善后开源&lt;/li&gt;
&lt;li&gt;给积分商城和弹幕机器人加了签到系统&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;从这个阶段开始，我做项目时的关注点慢慢从&lt;br&gt;
&lt;strong&gt;功能写没写完&lt;/strong&gt; 变成了 &lt;strong&gt;下次还要不要再写一遍&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;四月-封装开始变成一件很自然的事&lt;/h2&gt;
&lt;p&gt;四月基本可以算是一个「封装月」。&lt;/p&gt;
&lt;p&gt;我把之前在项目里零零碎碎用到的一些功能，开始系统性地拆出来，变成独立的包：&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/zxc7563598/php-mobile-locator&quot;&gt;手机号归属地查询&lt;/a&gt;、&lt;a href=&quot;https://github.com/zxc7563598/php-china-division&quot;&gt;身份证归属地查询&lt;/a&gt;、&lt;a href=&quot;https://github.com/zxc7563598/php-address-parser&quot;&gt;地址解析&lt;/a&gt;、&lt;a href=&quot;https://github.com/zxc7563598/php-url-signer&quot;&gt;URL 签名&lt;/a&gt;、&lt;a href=&quot;https://github.com/zxc7563598/php-google-authenticator&quot;&gt;TOTP 校验&lt;/a&gt;、&lt;a href=&quot;https://github.com/zxc7563598/php-simple-rule-engine&quot;&gt;规则引擎&lt;/a&gt;，还有一堆 &lt;a href=&quot;https://github.com/zxc7563598/php-utils&quot;&gt;utils&lt;/a&gt; 级别的小工具。&lt;/p&gt;
&lt;p&gt;它们大多不复杂，也谈不上什么设计模式，但都有一个共同点：&lt;br&gt;
&lt;strong&gt;我真的在项目里反复用到它们。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;同一时间，积分商城和弹幕机器人也加上了 PK 播报能力，开始慢慢往「更像一个完整系统」的方向走。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;五月-从我在用到我要维护&lt;/h2&gt;
&lt;p&gt;五月我开始认真对待 GitHub 上的这些仓库。&lt;/p&gt;
&lt;p&gt;我给不少项目补了双语 README，重构了积分商城和弹幕机器人的管理后台，也终于给商城首页加上了分页这个功能，还有一些校验之类的。&lt;/p&gt;
&lt;p&gt;其实一开始就该有，只是当初完全没想到真的会有这么多人用。&lt;/p&gt;
&lt;p&gt;也是在这个阶段，我第一次明确意识到：&lt;strong&gt;这个项目不能再按“自用工具”的标准来对待了。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这个月我还沉迷了一阵子 AI，给自己搞了一套每日热点新闻推送，每天早上自动收集、处理，只留下我真正可能感兴趣的内容。纯粹是为了让自己少刷一点无意义的信息流。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;六到八月-系统开始变复杂&lt;/h2&gt;
&lt;p&gt;这几个月，积分商城和弹幕机器人的变化开始变得比较明显：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;拆分货币体系（因为根据需求加入了签到系统，而签到系统得到的奖励代币又要跟上舰的积分有所区别）&lt;/li&gt;
&lt;li&gt;引入返利和双货币逻辑&lt;/li&gt;
&lt;li&gt;完善了接口加密逻辑，分别封装成 npm 包和 PHP 包&lt;/li&gt;
&lt;li&gt;给项目接口的加密方式都升级成了封装的 AES + RSA 混合加密&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这些变化大多不是「我想加」，而是​&lt;strong&gt;有人真的反馈过来了需要使用，以及有认真的在使用的过程中遇到了问题&lt;/strong&gt;，于是不得不去想更合理的设计。&lt;/p&gt;
&lt;p&gt;与此同时，我也继续把工作中那些「懒得一次次封装」的东西独立出来，比如 &lt;a href=&quot;https://github.com/zxc7563598/php-ip138&quot;&gt;ip138接口封装&lt;/a&gt;、&lt;a href=&quot;https://github.com/zxc7563598/php-promotion-engine&quot;&gt;促销策略引擎&lt;/a&gt;、&lt;a href=&quot;https://github.com/zxc7563598/php-id-generator&quot;&gt;ID 生成器&lt;/a&gt;，还有一个让老板自己查数据库的&lt;a href=&quot;https://github.com/zxc7563598/data-query-tool&quot;&gt;小工具&lt;/a&gt;。&lt;/p&gt;
&lt;p&gt;有些工具，说实话，做出来就是为了让我清净一点。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;九到十月-换工具解决问题&lt;/h2&gt;
&lt;p&gt;这段时间我做的事情跨度有点大。&lt;/p&gt;
&lt;p&gt;B 站小小的发力，更新了一轮风控，大部分接口都需要通过 WBI 签名鉴权了，不过好在问题不是很大，顺利解决了。&lt;/p&gt;
&lt;p&gt;直播间数据流也迎来了一波更新，部分数据开始通过 protobuf 传输，protobuf 逆向确实让我头疼了两天，不过好在 B 站对这一部分貌似也是浅尝辄止，并没有大范围推广，因此解决这个问题并没有产生天大的工作量。&lt;/p&gt;
&lt;p&gt;也首次尝试用 Wails 和 Go 做桌面应用。&lt;/p&gt;
&lt;p&gt;还有一个天大的事情，九月份我发现了一个不是很健康的，带颜色的小说网站的漏洞。&lt;/p&gt;
&lt;p&gt;色情果然是除战争外的第一生产力推动力量，火速用 python 搞了个简易爬虫，爬了人家八万多篇小短文，可惜人家在十二月中的时候改版了整个系统，在后端把爬虫堵死了。&lt;/p&gt;
&lt;p&gt;虽然说感觉如果可以伪造IP，好好做一个爬虫的话应该还可以爬，但是他一个月都更新不了30篇，八万多篇已经不知道是他多少年的存货了，我也不想在这个事情上折腾太久，八万多篇都不知道看到猴年马月了。&lt;/p&gt;
&lt;p&gt;然后因为想要更高效的筛选小说，比如我搜索一个情景或者剧情方向，就给我找到最符合方向的小说，因此用到了向量搜索，去研究了 Milvus，做了向量数据库的 REST 封装并且公开了。&lt;/p&gt;
&lt;p&gt;当然只是公开了向量数据库相关的封装，并没有公开小说，公开那玩意违法，只能我自己享用了。&lt;/p&gt;
&lt;p&gt;以前的我可能会在需要实现某种功能时投入更高的技术能力硬莽过去，而现在，在工作中慢慢失去了那股热情的我，在面对一些语言天生劣势的时候比起硬莽可能会更加倾向于去更换具备足够优势的语言。&lt;/p&gt;
&lt;p&gt;感觉就好像年轻的时候坚信&lt;strong&gt;一力降十会&lt;/strong&gt;，老了之后跟别人讲&lt;strong&gt;一巧破百拙&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;不过只要解决了问题就好，了解的东西增加了之后确实会慢慢摆脱那种既分高下也决生死的冲劲，我说不上这是不是好事，但目前想来也还不觉得是个坏事。&lt;/p&gt;
&lt;p&gt;同一时间，在我使用了一段时间 Hexo 作为自己的博客框架后，也渐渐感到不太满意，最后决定把博客迁移到 Astro，并顺手把之前散落在各处的 demo 和小工具慢慢收拢回来。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;十一到十二月-开始维护自己的空间&lt;/h2&gt;
&lt;p&gt;年底的节奏明显慢了下来，可能工作上的事情多了一点点，出差那些让我没有那么多闲心去折腾别的。&lt;/p&gt;
&lt;p&gt;我把更多精力放在整理和维护上：
继续打磨 Astro 博客，补文档，做提问箱，完善小工具页面；&lt;/p&gt;
&lt;p&gt;给积分商城和弹幕机器人补齐文档；&lt;/p&gt;
&lt;p&gt;把错误日志相关的工具重新整理了一下；&lt;/p&gt;
&lt;p&gt;顺手写了一些真正能长期用的小工具。&lt;/p&gt;
&lt;p&gt;这些事情本身并不刺激，但它们让我第一次产生了一种比较踏实的感觉：有些东西，我可能会一直维护下去。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;写在最后&lt;/h2&gt;
&lt;p&gt;我一直不觉得自己是个会写文章的人。&lt;/p&gt;
&lt;p&gt;对我来说，大多数博客的产生方式都很固定：&lt;strong&gt;先遇到一个问题，解决它，然后顺手记下来。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;回头看这一年，做过的东西确实不少，但真正重要的，可能并不是具体写了多少代码、开了多少仓库，而是我越来越清楚自己想要什么样的节奏。&lt;/p&gt;
&lt;p&gt;不是在浪尖上冲刺，也不是彻底躺平，而是尽量让自己始终处在一个还能持续产出、也能长期维持的状态里。&lt;/p&gt;
&lt;p&gt;比起对某项技术的热情，或者研究了多少看起来很厉害的东西，我可能更在意的，是自己在未来的环境里，能不能持续创造出有价值的东西。&lt;/p&gt;
&lt;p&gt;当然，也会有人觉得，进入所谓的 AI 时代之后，很多事情老板张张嘴、让 AI 做就行了，那这些东西自然也就不值钱了。&lt;/p&gt;
&lt;p&gt;但实际情况往往没这么简单。&lt;/p&gt;
&lt;p&gt;老板对着 AI 随便描述一句需求，和一个有经验的人带着明确的问题、约束和判断去用 AI，最终得到的结果，差别是非常大的。&lt;/p&gt;
&lt;p&gt;工具在变，但解决问题的经验，以及看问题、拆问题、兜底问题的能力，并不会因此消失。它们也许会随着泡沫的起落而被低估，但很难真正变得一文不值。&lt;/p&gt;
&lt;p&gt;就像过去的八级钳工一样。随着数控机床的精度不断提高，单纯去和机器比精度，确实已经没有太多意义了。但在非标场景下如何处理问题，设备出现异常时如何兜底，这些才是八级工真正有价值的地方。&lt;/p&gt;
&lt;p&gt;对我来说，这一年做的很多事情，本质上也是在不断积累这些东西。&lt;/p&gt;
&lt;p&gt;它们不一定时髦，也未必耀眼，但至少让我确信一件事：&lt;strong&gt;只要还能持续解决问题，我就还站在牌桌上&lt;/strong&gt;&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>一次受限环境下的 MySQL 数据导出与“可交付化”实践</title><link>https://hejunjie.life/blog/ka8dy46f</link><guid isPermaLink="true">https://hejunjie.life/blog/ka8dy46f</guid><description>记录一次在受限条件下整理 MySQL 数据的过程：面对陌生数据库和时间限制，如何先保证数据完整，再把数据整理成非技术人员也能直接使用的形式。</description><pubDate>Fri, 26 Dec 2025 08:39:17 GMT</pubDate><content:encoded>&lt;p&gt;平时其实很少会专门写数据库导出的事情。&lt;/p&gt;
&lt;p&gt;这种活本身并不复杂，零零散散也做过很多次，大多数时候也不会留下什么记录。&lt;/p&gt;
&lt;p&gt;这一次之所以单独记下来，主要还是因为当时遇到了一些​&lt;strong&gt;比较具体、也比较现实的限制条件&lt;/strong&gt;：&lt;/p&gt;
&lt;p&gt;我需要在比较短的时间里接手一个并不熟悉的 MySQL 实例，把里面的数据整理出来，而且这些数据最终并不是只给工程师看。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;从一开始就意识到的一个问题&lt;/h2&gt;
&lt;p&gt;在动手之前，其实有一件事情我是比较明确的：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;​ &lt;strong&gt;​&lt;code&gt;.sql&lt;/code&gt;​&lt;/strong&gt;​ &lt;strong&gt;文件对工程师很友好，但对非技术人员几乎没有可用性。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;对工程师来说：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;​&lt;code&gt;.sql&lt;/code&gt; 是最可靠的备份形式&lt;/li&gt;
&lt;li&gt;可以恢复、可以校验、可以长期保存&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;但换一个视角：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;很多人甚至不知道怎么打开 &lt;code&gt;.sql&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;就算打开了，也很难直接理解表结构&lt;/li&gt;
&lt;li&gt;想筛选、查某一条记录，几乎是不可能的&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;也就是说，​&lt;strong&gt;单纯把数据库备份下来，并不等于问题已经解决了&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;后面迟早还是要把数据整理成一种“能被直接使用”的形式。&lt;/p&gt;
&lt;p&gt;所以我当时心里其实是把这件事拆成了两步：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;先保证数据完整地留下来&lt;/li&gt;
&lt;li&gt;再考虑怎么把数据变成别人也能看懂的样子&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h2&gt;先做一份完整的数据库备份&lt;/h2&gt;
&lt;p&gt;基于这个判断，我做的第一件事，还是先把整个 MySQL 实例完整备份下来。&lt;/p&gt;
&lt;p&gt;这一步本身并不复杂，也谈不上什么技巧，只是对我来说，​&lt;strong&gt;先有一份全量、可恢复的备份，会比较安心&lt;/strong&gt;。后面无论怎么处理数据，至少不会有“回不去”的问题。&lt;/p&gt;
&lt;p&gt;为了省事，我写了一个简单的 shell 脚本，用来：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;自动获取所有业务数据库&lt;/li&gt;
&lt;li&gt;排除系统库&lt;/li&gt;
&lt;li&gt;逐个数据库执行 &lt;code&gt;mysqldump&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;直接流式压缩成 &lt;code&gt;.sql.gz&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;脚本本身也只是把平时常用的命令整理了一下：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;#!/usr/bin/env bash

## gunzip &amp;#x3C; app.sql.gz | mysql -u root -p
## nohup ./dump_all_dbs.sh host port root &apos;password&apos; &gt; 备份日志.log 2&gt;&amp;#x26;1 &amp;#x26;

set -e

HOST=&quot;$1&quot;
PORT=&quot;$2&quot;
USER=&quot;$3&quot;
PASS=&quot;$4&quot;

if [ $# -ne 4 ]; then
  echo &quot;Usage: $0 &amp;#x3C;host&gt; &amp;#x3C;port&gt; &amp;#x3C;user&gt; &amp;#x3C;password&gt;&quot;
  exit 1
fi

OUT_DIR=&quot;Mysql备份_$(date +%F_%H%M%S)&quot;
mkdir -p &quot;$OUT_DIR&quot;

MYSQL=&quot;mysql -h${HOST} -P${PORT} -u${USER} -p${PASS} --batch --skip-column-names&quot;
DUMP_BASE_OPTS=&quot;
  --single-transaction
  --routines
  --events
  --triggers
  --hex-blob
  --set-gtid-purged=OFF
  --default-character-set=utf8mb4
&quot;

echo &quot;==&gt; 正在从获取数据库列表 ${HOST}:${PORT}&quot;

DATABASES=$($MYSQL -e &quot;
  SELECT schema_name
  FROM information_schema.schemata
  WHERE schema_name NOT IN
    (&apos;mysql&apos;,&apos;information_schema&apos;,&apos;performance_schema&apos;,&apos;sys&apos;);
&quot;)

if [ -z &quot;$DATABASES&quot; ]; then
  echo &quot;未找到数据库!&quot;
  exit 0
fi

echo &quot;==&gt; 要转储的数据库:&quot;
echo &quot;$DATABASES&quot;
echo

for DB in $DATABASES; do
  FILE=&quot;${OUT_DIR}/${DB}.sql.gz&quot;
  echo &quot;==&gt; 转储数据库: ${DB}&quot;

  mysqldump \
    -h${HOST} -P${PORT} -u${USER} -p${PASS} \
    $DUMP_BASE_OPTS \
    --databases &quot;$DB&quot; \
    | gzip &gt; &quot;$FILE&quot;

  echo &quot;    -&gt; 完成: $FILE&quot;
done

echo
echo &quot;所有数据库均已成功转储.&quot;
echo &quot;输出目录: ${OUT_DIR}&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;做到这里，其实“数据有没有丢”这个问题就已经基本不用担心了。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;按需导出某一部分数据&lt;/h2&gt;
&lt;p&gt;接下来遇到的，是更偏实际使用层面的问题。&lt;/p&gt;
&lt;p&gt;在整理数据的过程中，经常会有一些很具体的需求，比如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;只需要看某一张表&lt;/li&gt;
&lt;li&gt;或者想先筛选一部分数据出来看看&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这时候，如果只剩下一堆 &lt;code&gt;.sql&lt;/code&gt; 文件，其实并不太好用。&lt;/p&gt;
&lt;p&gt;所以我写了一个很简单的 PHP CLI 脚本，用来把一条 SQL 查询的结果直接导出成 CSV。&lt;/p&gt;
&lt;p&gt;这个脚本的目标也很单纯：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;能处理数据量比较大的表&lt;/li&gt;
&lt;li&gt;不一次性把数据全部读进内存&lt;/li&gt;
&lt;li&gt;导出的文件可以直接用 Excel 打开&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;&amp;#x3C;?php

// 单文件 CLI：MySQL 导出 CSV

if ($argc &amp;#x3C; 2) {
    echo &amp;#x3C;&amp;#x3C;&amp;#x3C;HELP
Usage:
  php export.php &amp;#x3C;output_csv_path&gt;

Example:
  php export.php /data/output/users.csv

HELP;
    exit(1);
}

$outputCsv = $argv[1];

// MySQL 配置
$dbConfig = [
    &apos;host&apos;     =&gt; &apos;127.0.0.1&apos;,
    &apos;port&apos;     =&gt; 3306,
    &apos;dbname&apos;   =&gt; &apos;dbname&apos;,
    &apos;username&apos; =&gt; &apos;root&apos;,
    &apos;password&apos; =&gt; &apos;password&apos;,
    &apos;charset&apos;  =&gt; &apos;utf8mb4&apos;,
];

// SQL
$sql = &amp;#x3C;&amp;#x3C;&amp;#x3C;SQL
select * from bl_danmu_logs
SQL;


$dsn = sprintf(
    &apos;mysql:host=%s;port=%d;dbname=%s;charset=%s&apos;,
    $dbConfig[&apos;host&apos;],
    $dbConfig[&apos;port&apos;],
    $dbConfig[&apos;dbname&apos;],
    $dbConfig[&apos;charset&apos;]
);
try {
    $pdo = new PDO(
        $dsn,
        $dbConfig[&apos;username&apos;],
        $dbConfig[&apos;password&apos;],
        [
            PDO::ATTR_ERRMODE            =&gt; PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE =&gt; PDO::FETCH_ASSOC,
            PDO::MYSQL_ATTR_USE_BUFFERED_QUERY =&gt; false,
        ]
    );
} catch (PDOException $e) {
    fwrite(STDERR, &quot;数据库连接失败: {$e-&gt;getMessage()}\n&quot;);
    exit(1);
}
$dir = dirname($outputCsv);
if (!is_dir($dir)) {
    mkdir($dir, 0777, true);
}
$fp = fopen($outputCsv, &apos;w&apos;);
if ($fp === false) {
    fwrite(STDERR, &quot;无法写入 CSV 文件\n&quot;);
    exit(1);
}
fwrite($fp, &quot;\xEF\xBB\xBF&quot;);
$stmt = $pdo-&gt;prepare($sql);
$stmt-&gt;execute();
$rowCount = 0;
$headerWritten = false;
while ($row = $stmt-&gt;fetch()) {
    if (!$headerWritten) {
        fputcsv($fp, array_keys($row));
        $headerWritten = true;
    }
    fputcsv($fp, array_values($row));
    $rowCount++;
    if ($rowCount % 100000 === 0) {
        echo &quot;已导出 {$rowCount} 行\n&quot;;
    }
}
fclose($fp);
echo &quot;导出完成，共 {$rowCount} 行\n&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个脚本更多是用来应对一些临时、零散的导出需求，本身也不复杂。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;真正的难点在“交付”这一步&lt;/h2&gt;
&lt;p&gt;真正让我花时间的，其实是后面这一部分。&lt;/p&gt;
&lt;p&gt;如果只是从工程角度看，&lt;code&gt;.sql&lt;/code&gt;​ 已经足够完整；&lt;/p&gt;
&lt;p&gt;但从使用角度看，这些数据仍然&lt;strong&gt;很难被直接消费&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;问题包括：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;表很多，一个一个手工导出不现实&lt;/li&gt;
&lt;li&gt;Excel 有行数限制，大表没法一次性打开&lt;/li&gt;
&lt;li&gt;字段名是英文或缩写，不看表结构根本不知道是什么意思&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以后来我又写了一个脚本，用来把整个数据库的数据，整理成一组 CSV 文件。&lt;/p&gt;
&lt;p&gt;这个脚本做的事情也很朴素：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;遍历数据库中的所有表&lt;/li&gt;
&lt;li&gt;读取字段注释，作为 CSV 的表头&lt;/li&gt;
&lt;li&gt;数据量大的表按行数自动拆分文件&lt;/li&gt;
&lt;li&gt;所有文件都可以直接用 Excel 打开&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这些逻辑都不复杂，只是把原本需要重复做的事情集中到了一起。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;&amp;#x3C;?php

// 单文件 CLI：导出数据库指定表中所有的数据

const MAX_ROWS_PER_FILE = 1000000; // 每个CSV文件最大行数（包含表头）

if ($argc &amp;#x3C; 3) {
    echo &amp;#x3C;&amp;#x3C;&amp;#x3C;HELP
Usage:
  php export.php &amp;#x3C;output_folder_path&gt; &amp;#x3C;database_name&gt;

Example:
  php export.php /data/output/ dabatase_name

注意：每个CSV文件最多包含100万行数据，超过会拆分成多个文件

HELP;
    exit(1);
}

$outputFolder = rtrim($argv[1], &apos;/&apos;) . &apos;/&apos;;
$dbname = $argv[2];

// MySQL 配置
$dbConfig = [
    &apos;host&apos;     =&gt; &apos;127.0.0.1&apos;,
    &apos;port&apos;     =&gt; 3306,
    &apos;dbname&apos;   =&gt; $dbname,
    &apos;username&apos; =&gt; &apos;root&apos;,
    &apos;password&apos; =&gt; &apos;zxc7563598&apos;,
    &apos;charset&apos;  =&gt; &apos;utf8mb4&apos;,
];

$dsn = sprintf(
    &apos;mysql:host=%s;port=%d;dbname=%s;charset=%s&apos;,
    $dbConfig[&apos;host&apos;],
    $dbConfig[&apos;port&apos;],
    $dbConfig[&apos;dbname&apos;],
    $dbConfig[&apos;charset&apos;]
);

try {
    $pdo = new PDO(
        $dsn,
        $dbConfig[&apos;username&apos;],
        $dbConfig[&apos;password&apos;],
        [
            PDO::ATTR_ERRMODE            =&gt; PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE =&gt; PDO::FETCH_ASSOC,
            PDO::MYSQL_ATTR_USE_BUFFERED_QUERY =&gt; false,
        ]
    );
} catch (PDOException $e) {
    fwrite(STDERR, &quot;数据库连接失败: {$e-&gt;getMessage()}\n&quot;);
    exit(1);
}

// 确保输出文件夹存在
if (!is_dir($outputFolder)) {
    mkdir($outputFolder, 0777, true);
}

// 获取所有表名
try {
    $stmt = $pdo-&gt;query(&quot;SHOW TABLES&quot;);
    $tables = $stmt-&gt;fetchAll(PDO::FETCH_COLUMN);
    if (empty($tables)) {
        echo &quot;数据库中没有表\n&quot;;
        exit(0);
    }
    echo &quot;找到 &quot; . count($tables) . &quot; 个表\n&quot;;
    echo &quot;每个文件最多包含 &quot; . number_format(MAX_ROWS_PER_FILE) . &quot; 行数据\n\n&quot;;
    $totalRows = 0;
    $exportedTables = 0;
    $totalFiles = 0;
    foreach ($tables as $table) {
        echo &quot;正在处理表: {$table}...\n&quot;;
        // 获取表的字段信息（字段名和注释）
        $commentStmt = $pdo-&gt;prepare(&quot;
            SELECT 
                COLUMN_NAME, 
                COLUMN_COMMENT
            FROM INFORMATION_SCHEMA.COLUMNS 
            WHERE TABLE_SCHEMA = :database 
            AND TABLE_NAME = :table 
            ORDER BY ORDINAL_POSITION
        &quot;);
        $commentStmt-&gt;execute([
            &apos;:database&apos; =&gt; $dbConfig[&apos;dbname&apos;],
            &apos;:table&apos; =&gt; $table
        ]);
        $columns = $commentStmt-&gt;fetchAll();
        $headers = [];
        // 准备CSV表头：优先使用注释，没有注释则使用字段名
        foreach ($columns as $column) {
            $columnName = $column[&apos;COLUMN_NAME&apos;];
            $columnComment = trim($column[&apos;COLUMN_COMMENT&apos;]);
            // 如果注释不为空，使用注释；否则使用字段名
            $headers[$columnName] = !empty($columnComment) ? $columnComment : $columnName;
        }
        // 获取字段名数组，用于按顺序读取数据
        $columnNames = array_keys($headers);
        $headerRow = array_values($headers);
        // 查询表数据
        try {
            $sql = &quot;SELECT * FROM `&quot; . str_replace(&apos;`&apos;, &apos;``&apos;, $table) . &quot;`&quot;;
            $dataStmt = $pdo-&gt;prepare($sql);
            $dataStmt-&gt;execute();
            $rowCount = 0;
            $fileIndex = 0;
            $currentFileRows = 0;
            $fp = null;
            while ($row = $dataStmt-&gt;fetch()) {
                // 如果是新文件或文件未打开，创建新文件
                if ($fp === null || $currentFileRows &gt;= MAX_ROWS_PER_FILE) {
                    // 关闭之前的文件
                    if ($fp !== null) {
                        fclose($fp);
                        echo &quot;  -&gt; 文件 {$table}-{$fileIndex}.csv 完成 ({$currentFileRows} 行)\n&quot;;
                    }
                    // 创建新文件
                    $fileIndex++;
                    $currentFileRows = 0;
                    $outputCsv = $outputFolder . $table . &apos;-&apos; . $fileIndex . &apos;.csv&apos;;
                    $fp = fopen($outputCsv, &apos;w&apos;);
                    if ($fp === false) {
                        echo &quot;失败 (无法创建文件: {$outputCsv})\n&quot;;
                        break;
                    }
                    // 添加BOM头，确保Excel正确识别UTF-8
                    fwrite($fp, &quot;\xEF\xBB\xBF&quot;);
                    // 写入表头
                    fputcsv($fp, $headerRow);
                    $totalFiles++;
                }
                // 按照表头顺序组织数据
                $rowData = [];
                foreach ($columnNames as $columnName) {
                    $rowData[] = $row[$columnName] ?? &apos;&apos;;
                }
                fputcsv($fp, $rowData);
                $rowCount++;
                $currentFileRows++;
                // 每 10 万行打一次日志
                if ($rowCount % 100000 === 0) {
                    echo &quot;  -&gt; 已导出 &quot; . number_format($rowCount) . &quot; 行，当前文件: {$table}-{$fileIndex}.csv\n&quot;;
                }
            }
            // 关闭最后一个文件
            if ($fp !== null) {
                fclose($fp);
                if ($currentFileRows &gt; 0) {
                    echo &quot;  -&gt; 文件 {$table}-{$fileIndex}.csv 完成 ({$currentFileRows} 行)\n&quot;;
                }
            }
            $totalRows += $rowCount;
            $exportedTables++;
            // 确定实际创建的文件数量
            $actualFiles = $fileIndex; // fileIndex从1开始，所以最后的值就是文件数量
            if ($actualFiles &gt; 1) {
                echo &quot;表 {$table} 导出完成: &quot; . number_format($rowCount) . &quot; 行，拆分成 {$actualFiles} 个文件\n&quot;;
            } else {
                echo &quot;表 {$table} 导出完成: &quot; . number_format($rowCount) . &quot; 行\n&quot;;
            }
            // 重命名单文件情况下的文件名（去掉-1后缀）
            if ($actualFiles === 1) {
                $oldFile = $outputFolder . $table . &apos;-1.csv&apos;;
                $newFile = $outputFolder . $table . &apos;.csv&apos;;
                if (file_exists($oldFile) &amp;#x26;&amp;#x26; rename($oldFile, $newFile)) {
                    echo &quot;  -&gt; 重命名为: {$table}.csv\n&quot;;
                }
            }
            echo &quot;\n&quot;;
        } catch (Exception $e) {
            // 关闭可能打开的文件
            if ($fp !== null) {
                fclose($fp);
            }
            echo &quot;失败: &quot; . $e-&gt;getMessage() . &quot;\n&quot;;
            continue;
        }
    }
    echo &quot;========== 导出完成！ ==========\n&quot;;
    echo &quot;成功导出的表: {$exportedTables}/&quot; . count($tables) . &quot;\n&quot;;
    echo &quot;总数据行数: &quot; . number_format($totalRows) . &quot;\n&quot;;
    echo &quot;生成的CSV文件总数: {$totalFiles}\n&quot;;
    // 如果有的表导出失败，列出它们
    if ($exportedTables &amp;#x3C; count($tables)) {
        $failedTables = array_diff($tables, array_map(function ($csv) use ($outputFolder) {
            return preg_replace(&apos;/-\d+\.csv$/&apos;, &apos;.csv&apos;, basename($csv));
        }, glob($outputFolder . &apos;*.csv&apos;)));
        if (!empty($failedTables)) {
            echo &quot;导出失败的表:\n&quot;;
            foreach ($failedTables as $table) {
                echo &quot;  - {$table}\n&quot;;
            }
        }
    }
    echo &quot;\n文件命名规则:\n&quot;;
    echo &quot;- 数据量 ≤ &quot; . number_format(MAX_ROWS_PER_FILE) . &quot; 行: 表名.csv\n&quot;;
    echo &quot;- 数据量 &gt; &quot; . number_format(MAX_ROWS_PER_FILE) . &quot; 行: 表名-1.csv, 表名-2.csv, ...\n&quot;;
} catch (Exception $e) {
    fwrite(STDERR, &quot;获取表列表失败: {$e-&gt;getMessage()}\n&quot;);
    exit(1);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;一点事后的感受&lt;/h2&gt;
&lt;p&gt;这次整理下来，我的感受其实挺明确的：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;技术本身并不复杂&lt;/li&gt;
&lt;li&gt;真正需要花心思的，是&lt;strong&gt;站在使用者的角度去看数据&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;对工程师来说，数据库和 SQL 已经很直观了；&lt;/p&gt;
&lt;p&gt;但对不直接使用数据库的人来说，​&lt;strong&gt;Excel 才是他们真正熟悉的工具&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;这套脚本对我而言，并不是什么通用方案，只是当时在那个条件下，一种比较顺手、也能把事情做完的办法。&lt;/p&gt;
&lt;p&gt;记录下来，也只是给自己以后再遇到类似情况时，留一个参考。&lt;/p&gt;</content:encoded><h:img src="/_astro/php.DoApYH8-.png"/><enclosure url="/_astro/php.DoApYH8-.png"/></item><item><title>用 Go 像写 Web 一样做桌面应用：完全离线的手机号归属地查询工具</title><link>https://hejunjie.life/blog/id81jf9d</link><guid isPermaLink="true">https://hejunjie.life/blog/id81jf9d</guid><description>分享了我用 Go + Wails 像写 Web 一样开发桌面应用的实践过程，并以一个完全离线的手机号归属地查询工具为例，记录从技术选型到落地实现的一些思考与经验</description><pubDate>Wed, 17 Dec 2025 08:00:11 GMT</pubDate><content:encoded>&lt;p&gt;前阵子我做了一个小工具：一个&lt;strong&gt;完全离线的手机号归属地查询桌面应用&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;功能本身其实并不复杂，但在这个过程中，我反而重新认识了一次&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;用 Go 做桌面应用，其实可以非常像在写一个 Web 项目。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这篇文章不打算讲手机号归属地怎么查（那真的很简单，如果你需要直接到文章底部仓库下载我做好的工具就好），而是想分享一下：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;为什么我会选择用 Go + Wails 做成桌面应用，以及这个过程里的一些实际感受。&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;一个并不复杂的问题&lt;/h2&gt;
&lt;p&gt;如果不考虑携号转网，手机号归属地这件事本身并不复杂。&lt;/p&gt;
&lt;p&gt;每个手机号在规划阶段，&lt;strong&gt;前七位&lt;/strong&gt;就已经确定了对应的：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;运营商&lt;/li&gt;
&lt;li&gt;省份 / 城市&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以理论上，只要你手里有一份号段库，查询逻辑无非就是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;取前七位 → 查表 → 返回结果&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;这类数据也并不是什么秘密。&lt;/p&gt;
&lt;p&gt;去 GitHub 看一眼，不同语言都有现成的库；百度搜一下，也有不少在线查询网站。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;问题不在于“能不能做”，而在于“怎么用得顺不顺”。&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;为什么现有方案总感觉不太对&lt;/h2&gt;
&lt;p&gt;在真实使用场景里，我遇到的需求通常是这样的：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;机器 &lt;strong&gt;不能联网&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;需要一次性处理 &lt;strong&gt;几十万甚至上百万个手机号&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;只是想快速区分归属地，不想额外搭服务&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这时候就会发现：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Web 方案&lt;/strong&gt;&lt;br&gt;
在线查询适合查一两个号码，但一旦涉及大批量导入（几十上百万的数据）或者涉及隐私问题不方便泄漏这些手机号，就会变得很尴尬。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;脚本 / 代码库&lt;/strong&gt;&lt;br&gt;
不同语言需要不同环境，作为有开发环境的自己用还好，给普通堆代码一窍不通的人用成本就很高了。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;我想要的其实是一个很简单的东西：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;一个不联网、不装环境，双击就能用的工具。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;于是一个想法就冒出来了：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;那为什么不直接做成一个 Windows / macOS 的桌面应用？&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;为什么是 Go + Wails&lt;/h2&gt;
&lt;p&gt;我之前用 Wails 简单做过一个 PC 端的财务管理应用，但那次更多是“试水”：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Go 当 Web 服务端&lt;/li&gt;
&lt;li&gt;Vue 打包进桌面&lt;/li&gt;
&lt;li&gt;本质还是一套前后端分离的 Web 思路&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这次我反而想换个方式，​&lt;strong&gt;尽量按照 Wails 的设计方式完整走一遍&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;选择它的原因也很直接：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Go&lt;/strong&gt;
&lt;ul&gt;
&lt;li&gt;编译后就是一个可执行文件&lt;/li&gt;
&lt;li&gt;非常适合做本地工具&lt;/li&gt;
&lt;li&gt;处理本地数据、文件都很舒服&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Wails&lt;/strong&gt;
&lt;ul&gt;
&lt;li&gt;用 Web 技术写桌面应用&lt;/li&gt;
&lt;li&gt;不需要起 HTTP 服务&lt;/li&gt;
&lt;li&gt;前端可以直接调用 Go 方法&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;我平时用 Vue 比较多，所以直接用：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;wails init -n 项目名 -t vue
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Wails 支持的模板其实不少，React、Vue、Svelte 都有，翻一翻文档基本都能找到，这里就不展开了。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;和传统 Web 最大的不同：没有路由&lt;/h2&gt;
&lt;p&gt;如果你是做 Web 开发的，上手 Wails 会非常快。&lt;/p&gt;
&lt;p&gt;传统 Web 项目里，我们习惯的是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Router（路由） → Handler（HTTP处理器） → Service（业务逻辑层） → Repository（模型访问层） → Model（数据模型）
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;请求通过路由分发到 Handler，再一层层往下走。&lt;/p&gt;
&lt;p&gt;而在 Wails 里：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;不需要路由&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;​&lt;code&gt;app.go&lt;/code&gt; 里的方法，会自动暴露给前端&lt;/li&gt;
&lt;li&gt;前端直接把它当成一个函数来调用&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;换个角度看：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;​&lt;code&gt;app.go&lt;/code&gt; 里的方法，其实就相当于传统 Web 里的 Router + Handler&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;至于 Service、Repository、Model 这些分层，​&lt;strong&gt;完全可以照搬&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;只是“请求”不再是 HTTP，而是一次本地方法调用。&lt;/p&gt;
&lt;p&gt;这个点让我感觉非常舒服：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;开发思路几乎没变，只是把“接口”换成了函数。&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;48 万条数据，SQLite 该怎么放&lt;/h2&gt;
&lt;p&gt;这个项目里有一个比较现实的问题：&lt;/p&gt;
&lt;p&gt;我内置了 ​&lt;strong&gt;48 万多条手机号号段数据&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;SQLite 本身非常适合这种场景，但如果在应用启动时再一条条初始化写入数据库，体验会非常糟糕。&lt;/p&gt;
&lt;p&gt;所以我的做法是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;提前生成一个完整的&lt;/strong&gt;  &lt;strong&gt;​&lt;code&gt;.db&lt;/code&gt;​&lt;/strong&gt;​ &lt;strong&gt;文件&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;在构建时，通过 &lt;code&gt;embed.FS&lt;/code&gt; 把这个数据库文件带进程序&lt;/li&gt;
&lt;li&gt;程序启动时：
&lt;ul&gt;
&lt;li&gt;如果用户本地还没有数据库&lt;/li&gt;
&lt;li&gt;就直接把这份已经初始化好的 &lt;code&gt;.db&lt;/code&gt; 拷贝过去&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这样一来：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;启动速度很快&lt;/li&gt;
&lt;li&gt;不需要额外初始化逻辑&lt;/li&gt;
&lt;li&gt;数据也完全可控、可更新&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这一步做完，后面的事情就简单很多了。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;当然，考虑到数据会更新，我预留了构建脚本，方便开发过程中构建这个 ​&lt;code&gt;.db&lt;/code&gt;​ 文件&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h2&gt;开发体验：真的很像在写 Web&lt;/h2&gt;
&lt;p&gt;剩下的开发过程，基本就是“Web 开发的本地版”：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Go 这边写好查询服务&lt;/li&gt;
&lt;li&gt;在 &lt;code&gt;app.go&lt;/code&gt; 封装成方法&lt;/li&gt;
&lt;li&gt;前端直接调用，不需要网络请求&lt;/li&gt;
&lt;li&gt;​&lt;code&gt;wails build&lt;/code&gt; 一次性完成：
&lt;ul&gt;
&lt;li&gt;前端打包&lt;/li&gt;
&lt;li&gt;后端编译&lt;/li&gt;
&lt;li&gt;桌面应用生成&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;项目放在 GitHub 上之后，再配合 GitHub Actions，就可以自动构建 Windows / macOS 的可执行文件，整个流程非常顺。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;一个很小的项目，但这个思路很实用&lt;/h2&gt;
&lt;p&gt;这个项目本身并不复杂，代码量也不多，我也尽量写了比较完整的注释。&lt;/p&gt;
&lt;p&gt;如果你：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;想试试 &lt;strong&gt;用 Go 写桌面应用&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;又或者只是需要一个 &lt;strong&gt;离线的手机号归属地查询工具&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;都可以看看这个项目，或者直接下载编译好的程序来用。&lt;/p&gt;
&lt;p&gt;项目地址在这里：&lt;/p&gt;
&lt;p&gt;👉 &lt;a href=&quot;https://github.com/zxc7563598/go-mobile-locator&quot;&gt;https://github.com/zxc7563598/go-mobile-locator&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;有时候换一种“应用形态”，&lt;/p&gt;
&lt;p&gt;反而能让很多原本别扭的问题，一下子顺起来。&lt;/p&gt;</content:encoded><h:img src="/_astro/go.aBeHx0xJ.jpg"/><enclosure url="/_astro/go.aBeHx0xJ.jpg"/></item><item><title>用 PHP 解析 Protobuf 的坑与解法</title><link>https://hejunjie.life/blog/j4ufbb59</link><guid isPermaLink="true">https://hejunjie.life/blog/j4ufbb59</guid><description>分享 PHP 解析 Protobuf 的实战经验，涵盖从安装运行时库、生成 PHP 类到序列化/反序列化的完整流程，附示例代码，帮助开发者少踩坑</description><pubDate>Fri, 12 Dec 2025 06:59:01 GMT</pubDate><content:encoded>&lt;p&gt;前阵子做的一个直播弹幕的机器人，其中有一部分上游数据是通过 Protobuf 返回的。几个朋友问我怎么处理，但我发现大家对「PHP 解析 Protobuf」这件事多少有点迷糊。确实，PHP 处理 Protobuf 的资料不多，而且踩坑成本不算低。&lt;/p&gt;
&lt;p&gt;这篇文章不打算科普什么，也没有推荐任何技术栈的意思，就是把我自己摸索的过程整理出来，给遇到类似问题的人一个参考。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Protobuf 是什么&lt;/h2&gt;
&lt;p&gt;很多人第一次接触它时，会把它和 JSON、XML 放在一起理解，但 Protobuf 并不是“另一个 JSON”。它是一种 ​&lt;strong&gt;基于 Schema 的二进制数据格式&lt;/strong&gt;，本质上由两个部分组成：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;​&lt;code&gt;.proto&lt;/code&gt;：数据结构的描述文件（类似字典）&lt;/li&gt;
&lt;li&gt;二进制格式：根据 &lt;code&gt;.proto&lt;/code&gt; 规则编码出来的数据&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Google 发明它的原因大致是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;JSON 太大、太慢&lt;/li&gt;
&lt;li&gt;在高性能、跨语言通信场景里不够理想&lt;/li&gt;
&lt;li&gt;服务端内部大量 RPC 调用时，序列化效率太重要了&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;于是有了 Protobuf：数据格式紧凑、序列化速度快、跨语言支持也强。&lt;/p&gt;
&lt;p&gt;它不是为了可读性，而是为了性能。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;PHP 解析 Protobuf 为什么麻烦&lt;/h2&gt;
&lt;p&gt;PHP 能解析 Protobuf，但体验不如其他语言。原因有几个，简单列一下：&lt;/p&gt;
&lt;h3&gt;PHP 无法动态解析 Schema&lt;/h3&gt;
&lt;p&gt;像 Go、Python、Java 这类语言可以依靠 descriptor 动态解析 Protobuf 数据结构，甚至可以在运行期处理未知结构。&lt;/p&gt;
&lt;p&gt;PHP 目前做不到，没有暴露那一套 API。&lt;/p&gt;
&lt;p&gt;所以 PHP ​&lt;strong&gt;必须依赖 .proto 文件&lt;/strong&gt;，并且必须提前用 protoc 生成对应的 PHP 类。&lt;/p&gt;
&lt;h3&gt;PHP 的 Protobuf 扩展是“最小实现”&lt;/h3&gt;
&lt;p&gt;google/protobuf 的 PHP 扩展只提供：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;序列化：serialize&lt;/li&gt;
&lt;li&gt;反序列化：mergeFrom&lt;/li&gt;
&lt;li&gt;基本的 getter/setter 机制&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;其他高级能力基本没有。&lt;/p&gt;
&lt;h3&gt;PHP 的生态也不会把“解析二进制协议”当作主要用途&lt;/h3&gt;
&lt;p&gt;PHP 的常见使用场景偏 Web，因此处理二进制协议并不是重点。&lt;/p&gt;
&lt;h3&gt;并不是我们主动选择 Protobuf&lt;/h3&gt;
&lt;p&gt;在一些服务里，上游服务已经定死使用 Protobuf；或者 PHP 服务只是边缘网关，需要解析一次再转发。&lt;/p&gt;
&lt;p&gt;在这种情况下，只有硬着头皮支持。&lt;/p&gt;
&lt;p&gt;如果是自己的项目，并没有强约束，其实 JSON 足够了。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;PHP 如何使用 Protobuf&lt;/h2&gt;
&lt;p&gt;我自己在服务器上没有安装 Protobuf 扩展，而是采用更常见的一种方式：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;本地安装 &lt;code&gt;protoc&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;用 &lt;code&gt;.proto&lt;/code&gt; 文件生成 PHP 类&lt;/li&gt;
&lt;li&gt;服务器端只需要安装 &lt;code&gt;google/protobuf&lt;/code&gt; 包即可完成解析&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;第一步：安装运行时库&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;composer require google/protobuf
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这是 PHP 解析 Protobuf 所需的唯一运行时依赖。&lt;/p&gt;
&lt;h3&gt;第二步：安装 protoc（在本地）&lt;/h3&gt;
&lt;p&gt;protoc 是官方编译器，用于把 &lt;code&gt;.proto&lt;/code&gt; 文件生成各种语言的类（包括 PHP）。&lt;/p&gt;
&lt;p&gt;下载地址：&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/protocolbuffers/protobuf/releases&quot;&gt;https://github.com/protocolbuffers/protobuf/releases&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;选择对应平台的压缩包，解压后把 &lt;code&gt;protoc&lt;/code&gt; 放到 PATH 中即可。&lt;/p&gt;
&lt;p&gt;验证是否安装成功：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;protoc --version
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;.proto 文件是什么&lt;/h2&gt;
&lt;p&gt;​&lt;code&gt;.proto&lt;/code&gt; 文件可以简单理解为“数据结构的一份字典”。&lt;/p&gt;
&lt;p&gt;因为 Protobuf 的二进制格式里没有字段名，只有字段编号（tag）。&lt;/p&gt;
&lt;p&gt;例如：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;field #1:  123
field #2: &quot;Alice&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;你不知道 #1 是 &lt;code&gt;id&lt;/code&gt;​ 还是 &lt;code&gt;age&lt;/code&gt;​，也不知道 #2 是 &lt;code&gt;name&lt;/code&gt; 还是别的东西。&lt;/p&gt;
&lt;p&gt;所以必须依靠 &lt;code&gt;.proto&lt;/code&gt; 文件才能解码。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;使用 protoc 生成 PHP 类&lt;/h2&gt;
&lt;p&gt;我本地的命令大致如下：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;protoc --php_out=./protobuf \
       --proto_path=./protobuf \
       xxx.proto
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;含义如下：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;​&lt;code&gt;--php_out&lt;/code&gt;：生成的 PHP 文件存放位置&lt;/li&gt;
&lt;li&gt;​&lt;code&gt;--proto_path&lt;/code&gt;​：寻找 &lt;code&gt;.proto&lt;/code&gt; 的目录&lt;/li&gt;
&lt;li&gt;多个 .proto 可以一起编译&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;protoc 会根据 &lt;code&gt;.proto&lt;/code&gt;​ 内容生成一堆 PHP 类，每个 &lt;code&gt;message&lt;/code&gt; 对应一个 PHP 类，最终这些类会继承：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Google\Protobuf\Internal\Message
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;序列化、反序列化功能都来自这个基类。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;配置 Composer autoload&lt;/h2&gt;
&lt;p&gt;如果你希望通过命名空间加载生成的类，可以在 &lt;code&gt;composer.json&lt;/code&gt; 中加一条：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;&quot;autoload&quot;: {
    &quot;psr-4&quot;: {
        &quot;Proto\\&quot;: &quot;protobuf/&quot;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后执行：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;composer dumpautoload
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;在 PHP 中解析 Protobuf&lt;/h2&gt;
&lt;p&gt;解析的核心方法是：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;$msg-&gt;mergeFromString($binary)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;读完后，数据结构会自动填充在 message 对象里。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;在 PHP 中生成 Protobuf 数据&lt;/h2&gt;
&lt;p&gt;序列化对应的方法是：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;$binary = $msg-&gt;serializeToString();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;得到的就是一段 protobuf 二进制字符串，可以直接发送到网络或写入文件。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;快速测试&lt;/h2&gt;
&lt;p&gt;创建一个项目，目录结构如下：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;.
├── protobuf
│   └── TEST_USER_INFO.proto
└── test.php
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;TEST_USER_INFO.proto&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-proto&quot;&gt;syntax = &quot;proto3&quot;;

option php_namespace = &quot;TestUserInfo&quot;;

message User {
  int32 id = 1;
  string name = 2;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;test.php&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;&amp;#x3C;?php

require __DIR__ . &apos;/vendor/autoload.php&apos;;

use TestUserInfo\User;

$u1 = new User();
$u1-&gt;setId(7);
$u1-&gt;setName(&quot;PHP Encode Test&quot;);

$bin = $u1-&gt;serializeToString();

$u2 = new User();
$u2-&gt;mergeFromString($bin);

var_dump([
    &apos;原始数据&apos; =&gt; bin2hex($bin),
    &apos;id&apos; =&gt; $u2-&gt;getId(),
    &apos;name&apos; =&gt; $u2-&gt;getName(),
]);
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3&gt;安装运行时库&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;composer require google/protobuf
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3&gt;编译 .proto 文件&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;protoc --php_out=./protobuf --proto_path=./protobuf TEST_USER_INFO.proto
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这会在 &lt;code&gt;protobuf/&lt;/code&gt; 目录下生成 PHP 类文件，供 PHP 使用。&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;配置 Composer autoload&lt;/h3&gt;
&lt;p&gt;在 &lt;code&gt;composer.json&lt;/code&gt; 中增加命名空间映射，例如：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;require&quot;: {
    &quot;google/protobuf&quot;: &quot;^4.33&quot;
  },
  &quot;autoload&quot;: {
    &quot;psr-4&quot;: {
      &quot;GPBMetadata\\&quot;: &quot;protobuf/GPBMetadata&quot;,
      &quot;TestUserInfo\\&quot;: &quot;protobuf/TestUserInfo&quot;
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后重新加载 Composer 自动加载：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;composer clear-cache &amp;#x26;&amp;#x26; composer dump-autoload -o
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;运行测试&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;php test.php
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;运行后，你会看到序列化再反序列化的数据被正确输出，证明 PHP 成功处理了 Protobuf 数据。&lt;/p&gt;
&lt;h2&gt;写在最后&lt;/h2&gt;
&lt;p&gt;PHP 解析 Protobuf 的体验确实不算好，但能用，并且在某些需要兼容上游服务的场景里还是必须用。&lt;br&gt;
如果你也正在处理类似的数据，希望这篇文章能帮你少踩点坑。&lt;/p&gt;
&lt;p&gt;如果感觉文章里哪部分还没说清楚，欢迎继续交流。&lt;/p&gt;</content:encoded><h:img src="/_astro/php.DoApYH8-.png"/><enclosure url="/_astro/php.DoApYH8-.png"/></item><item><title>从零开始做 Go 项目：我的目录设计分享</title><link>https://hejunjie.life/blog/aod5ngj6</link><guid isPermaLink="true">https://hejunjie.life/blog/aod5ngj6</guid><description>分享的是我个人在做 Go 项目时整理的目录设计和分层思路，仅供参考，并不是什么绝对正确的做法。如果对你有所启发，那就太好了；如果你有不同的方式，也欢迎交流</description><pubDate>Mon, 01 Dec 2025 14:44:37 GMT</pubDate><content:encoded>&lt;h2&gt;为什么要先规划目录结构&lt;/h2&gt;
&lt;p&gt;刚开始写 Go 项目的时候，我对目录结构这件事格外重视。对于刚入门的人来说，开发过程中几乎每件事情都是第一次：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;全局配置怎么放？&lt;/li&gt;
&lt;li&gt;数据库连接怎么初始化？&lt;/li&gt;
&lt;li&gt;Model 怎么设计？&lt;/li&gt;
&lt;li&gt;路由怎么组织？&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;每一个点都能让人迷茫。&lt;/p&gt;
&lt;p&gt;相比之下，像 Java 的 Spring Boot 或 PHP 的 Laravel 都会给你一套“默认结构”，至少能让你知道应该往哪里放东西。&lt;/p&gt;
&lt;p&gt;而 Go 更自由，没有强迫你必须怎么做。对老手是好事，对新手却可能会踩坑。&lt;/p&gt;
&lt;p&gt;我的经验是：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;合理的目录结构&lt;/strong&gt;  &lt;strong&gt;=&lt;/strong&gt;  &lt;strong&gt;清晰的思路 + 舒适的开发体验&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;规划好结构之后，你可以把问题拆开逐个去实现，而不是一上来就面对一坨混乱的代码。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;我的 Go 项目目录结构（Gin 示例）&lt;/h2&gt;
&lt;p&gt;下面是我常用的目录结构，适合大多数中小型 Web 项目：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.
├── cmd/              # 项目入口目录
│   └── main.go       # 主程序入口
├── internal/         # 私有代码目录
│   ├── dto/          # 结构体定义目录
│   ├── handler/      # HTTP处理器(等同于Controller)
│   ├── model/        # 数据模型
│   ├── repository/   # 数据模型访问层
│   ├── router/       # 路由
│   └── service/      # 业务逻辑层
├── pkg/              # 可被外部引用的包
│   ├── config/       # 配置
│   ├── i18n/         # 国际化
│   ├── jwt/          # jwt
│   ├── middleware/   # 中间件
│   └── utils/        # 工具函数
├── api/              # API文档(Swagger)
│   └── swagger.json
├── scripts/          # 构建、部署脚本目录
├── go.mod
├── LICENSE
├── Makefile          # 自动化脚本说明书
└── README.md
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;cmd/：项目入口&lt;/h2&gt;
&lt;p&gt;​&lt;code&gt;cmd&lt;/code&gt; 存放整个项目的入口程序。&lt;/p&gt;
&lt;p&gt;通常项目只有一个：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cmd/
└── main.go
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果未来拆成多个服务（如 API + Worker），结构可以扩展成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cmd/
├── api/
│   └── main.go
├── worker/
│   └── main.go
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样多服务更好管理。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;internal/：项目私有代码&lt;/h2&gt;
&lt;p&gt;​&lt;code&gt;internal&lt;/code&gt;​ 是 Go 官方定义的 ​&lt;strong&gt;私有代码机制&lt;/strong&gt;：这里的包只能本项目引用，外部无法导入。&lt;/p&gt;
&lt;p&gt;我会把它拆成几个层次，职责分明：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;internal/
├── dto/          # 请求/响应结构体
├── handler/      # HTTP 层
├── service/      # 业务逻辑层
├── repository/   # 数据访问层
├── model/        # 数据模型
└── router/       # 路由注册
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;调用链是单向的：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Handler → Service → Repository → Model&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;下面简单说一下每层负责什么：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;​&lt;strong&gt;Handler&lt;/strong&gt;：接收/解析请求，参数校验，调用 Service&lt;/li&gt;
&lt;li&gt;​&lt;strong&gt;Service&lt;/strong&gt;：业务逻辑处理&lt;/li&gt;
&lt;li&gt;​&lt;strong&gt;Repository&lt;/strong&gt;：数据库读写（纯 CRUD，不写业务）&lt;/li&gt;
&lt;li&gt;​&lt;strong&gt;Model&lt;/strong&gt;：数据库结构体&lt;/li&gt;
&lt;li&gt;​&lt;strong&gt;DTO&lt;/strong&gt;：请求/响应结构（隔离 Model）&lt;/li&gt;
&lt;li&gt;​&lt;strong&gt;Router&lt;/strong&gt;：注册路由，绑定 Handler&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这种分层结构很适合 Go，也比 PHP 那种“一个 controller 干到底”的写法更好维护。&lt;/p&gt;
&lt;p&gt;另外，Go 的一个特点是：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;同一个 package 下多个&lt;/strong&gt;  &lt;strong&gt;​.go​&lt;/strong&gt;​ &lt;strong&gt;文件对编译器来说等同于同一个文件。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;所以可以自由地按模块拆成多个 &lt;code&gt;user.go&lt;/code&gt;​、&lt;code&gt;order.go&lt;/code&gt;，对程序来说他们直接就是一个文件不会有任何影响，但分开之后代码的可读性会极大的提高。&lt;/p&gt;
&lt;p&gt;如果你有其他面向对象语言的经验，Go会带来全新的编程体验。核心的思维转变可归纳为四点：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;忘掉&quot;类&quot;&lt;/strong&gt;  → 记住&quot;结构体+方法&quot;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;忘掉&quot;继承&quot;&lt;/strong&gt;  → 使用&quot;组合+接口&quot;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;忘掉&quot;设计模式套用&quot;&lt;/strong&gt;  → 关注&quot;解决问题&quot;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;接受显式错误处理&lt;/strong&gt; → 不再有try-catch&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h2&gt;pkg/：可复用的公共包&lt;/h2&gt;
&lt;p&gt;​&lt;code&gt;pkg&lt;/code&gt; 用来放可被其他项目引用的通用模块，例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pkg/
├── config/
├── i18n/
├── jwt/
├── middleware/
└── utils/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果只是给当前项目用，不需要放在 &lt;code&gt;pkg&lt;/code&gt;​，尽量归到 &lt;code&gt;internal&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;另外也建议不要堆一个“大 utils”，按功能拆分更容易维护。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;api/：API 文档&lt;/h2&gt;
&lt;p&gt;这里一般放 Swagger / OpenAPI 的定义文件（JSON/YAML）。&lt;/p&gt;
&lt;p&gt;用这些工具来管理接口文档可以：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;自动生成文档&lt;/li&gt;
&lt;li&gt;保持前后端对齐&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;对于多人协作非常有用。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Makefile：常用命令的“快捷键”&lt;/h2&gt;
&lt;p&gt;我一般会把常用命令写进 &lt;code&gt;Makefile&lt;/code&gt;，比如：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-makefile&quot;&gt;run:
    go run cmd/main.go

build:
    go build -o bin/app cmd/main.go

test:
    go test ./...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Makefile 是个好东西，你可以把各种项目会用到的命令像清单一样列出来。&lt;/p&gt;
&lt;p&gt;如果你有心思，甚至可以去做一个 help 命令来告诉其他人都有什么命令&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-makefile&quot;&gt;help:
	@echo &quot;可用指令：&quot;
	@echo &quot;  make deps          # 安装 / 更新 Go 依赖&quot;
	@echo &quot;  make fmt           # 统一格式化后端 Go 代码&quot;
	@echo &quot;  make test          # 执行全部 Go 单元测试&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;对新同事非常友好，哪怕不了解项目，也可以通过阅读 Makefile 快速上手项目。&lt;/p&gt;
&lt;hr&gt;
&lt;h1&gt;总结&lt;/h1&gt;
&lt;p&gt;对我来说，一旦目录结构定下来，剩下的基本就像做“填空题”一样：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;中间件怎么写&lt;/li&gt;
&lt;li&gt;JWT 怎么做&lt;/li&gt;
&lt;li&gt;数据库要建哪些表&lt;/li&gt;
&lt;li&gt;Handler / Service / Repository 各自实现哪些逻辑&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这些都能一块一块稳稳推进。&lt;/p&gt;
&lt;p&gt;Go 社区里没有唯一正确的结构，但这套分层思路对新手来说非常友好，也足够应对大部分项目。&lt;/p&gt;
&lt;p&gt;如果你正在准备写一个 Go 项目，希望这套结构能帮你少踩一些坑，也让你的项目更容易维护。&lt;/p&gt;</content:encoded><h:img src="/_astro/go.aBeHx0xJ.jpg"/><enclosure url="/_astro/go.aBeHx0xJ.jpg"/></item><item><title>MineContext：我第一次感觉 AI 真正在“主动帮我管理生活”</title><link>https://hejunjie.life/blog/vjr8wj4w</link><guid isPermaLink="true">https://hejunjie.life/blog/vjr8wj4w</guid><description>MineContext 是一个会在后台记录与整理你日常电脑行为的开源工具。它的体验更像是一种新的信息整理方式：不需要额外输入，你的日常操作本身就是素材。</description><pubDate>Thu, 20 Nov 2025 16:28:43 GMT</pubDate><content:encoded>&lt;p&gt;我现在一天基本离不开 AI 了。&lt;br&gt;
不是那种“把提示词写得像炼丹”式的依赖，而是很平常的那种：&lt;/p&gt;
&lt;p&gt;我写代码，它在旁边检查。&lt;br&gt;
我整理逻辑，它帮我捋一遍。&lt;br&gt;
我写文档，它补补关键字、给点建议。&lt;/p&gt;
&lt;p&gt;整个过程更像是 &lt;strong&gt;我在人前台写，它在后台兜底&lt;/strong&gt;。&lt;br&gt;
它不是替我工作，它是把我的工作做得更圆滑、更完整。&lt;/p&gt;
&lt;p&gt;但说实话，我过去对“AI 助理”的期待真的不高。&lt;br&gt;
因为不管助理多聪明，你不给输入，它就是个哑巴。&lt;/p&gt;
&lt;p&gt;它更像一个“贴心的编辑器”，而不是一个“主动的助理”。&lt;br&gt;
尤其是写日报、周报的时候。&lt;/p&gt;
&lt;p&gt;我不说，它啥都不知道。&lt;br&gt;
输入少，它就笨；输入多，我就累。&lt;/p&gt;
&lt;p&gt;所以一般都是：&lt;/p&gt;
&lt;p&gt;我亲自写 → AI 润色 → 我审核 → 再调整一下。&lt;/p&gt;
&lt;p&gt;直到我遇到了 ​&lt;strong&gt;MineContext&lt;/strong&gt;。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;直到我遇到 MineContext&lt;/h2&gt;
&lt;p&gt;有一天我随便逛 GitHub，看到一个叫 &lt;strong&gt;MineContext&lt;/strong&gt; 的东西。&lt;br&gt;
它确实在某种程度上展示了某种 AI 使用的新方向。&lt;/p&gt;
&lt;p&gt;​&lt;img src=&quot;article/vjr8wj4w/001.svg&quot; alt=&quot;&quot;&gt;​&lt;/p&gt;
&lt;p&gt;它不是等你给材料，它是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;自己看&lt;/li&gt;
&lt;li&gt;自己分析&lt;/li&gt;
&lt;li&gt;自己整理&lt;/li&gt;
&lt;li&gt;自己总结&lt;/li&gt;
&lt;li&gt;最后把报告端过来&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;我只是正常用电脑，它就在后台默默观察。&lt;/p&gt;
&lt;p&gt;等我晚上坐下来，它就会递给我一份报告，告诉我今天都做了什么。&lt;/p&gt;
&lt;p&gt;那份报告甚至比我自己写的更全面、更有逻辑、更有洞察。&lt;/p&gt;
&lt;p&gt;对于我这种喜欢把生活“数字化”来管理的人来说，这东西简直是天赐。&lt;br&gt;
我甚至隐隐觉得，这可能会成为新一代人的工作方式。&lt;br&gt;
&lt;strong&gt;AI 主动帮你记录、帮你整理，而不是你逼着自己记。&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;MineContext 到底在干什么？&lt;/h2&gt;
&lt;p&gt;用人话讲，它平时就在干这几件事：&lt;/p&gt;
&lt;h3&gt;&lt;strong&gt;定时截图你的屏幕&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;比如每隔几秒，它截一张当前屏幕。&lt;/p&gt;
&lt;h3&gt;&lt;strong&gt;用视觉模型分析你在干嘛&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;它能识别页面、文档、代码、聊天窗口、IDE、网页内容……&lt;br&gt;
然后把这些内容结构化。&lt;/p&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;你在写 PHP&lt;/li&gt;
&lt;li&gt;你在查某个 Cloudflare 500 报错&lt;/li&gt;
&lt;li&gt;你在写博客&lt;/li&gt;
&lt;li&gt;你在看一个项目的 README&lt;/li&gt;
&lt;li&gt;你在 debug&lt;/li&gt;
&lt;li&gt;你在管理数据库&lt;/li&gt;
&lt;li&gt;在跟桐桐聊天，她说她外公喝的茶 1680 一斤，你下单三斤并约定下周一面交（咳）&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;&lt;strong&gt;自动去重、过滤、归类&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;把无意义的截图清理掉，把重复信息合并掉，不会让你的数据变垃圾堆。&lt;/p&gt;
&lt;p&gt;​&lt;img src=&quot;article/vjr8wj4w/002.png&quot; alt=&quot;&quot;&gt;​&lt;/p&gt;
&lt;h3&gt;&lt;strong&gt;每一小段时间自动做小结&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;像一个实时存在的“精简时间线”。&lt;/p&gt;
&lt;p&gt;​&lt;img src=&quot;article/vjr8wj4w/003.png&quot; alt=&quot;&quot;&gt;​&lt;/p&gt;
&lt;h3&gt;&lt;strong&gt;生成洞察报告&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;每天、每周，它会把你这些碎片化行为整理成一份真正可读的报告。&lt;/p&gt;
&lt;p&gt;内容包括：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;你今天主要在做什么&lt;/li&gt;
&lt;li&gt;处理了哪些任务&lt;/li&gt;
&lt;li&gt;花了多少时间&lt;/li&gt;
&lt;li&gt;今天的重点主题是什么&lt;/li&gt;
&lt;li&gt;有哪些工作模式、习惯、偏好&lt;/li&gt;
&lt;li&gt;今天的 highlights / issues&lt;/li&gt;
&lt;li&gt;有时候甚至会给你建议&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;读起来真的就像把自己交给了一个观察者，它在旁边默默记录你的一天，晚上把故事整理好给你。&lt;/p&gt;
&lt;p&gt;​&lt;img src=&quot;article/vjr8wj4w/004.png&quot; alt=&quot;&quot;&gt;​&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;为什么我说它是 ADHD 神器&lt;/h2&gt;
&lt;p&gt;我不是专业讲 ADHD 的，就是站在“注意力容易飘”的普通人角度说一下。&lt;/p&gt;
&lt;p&gt;很多人应该都有这种感觉：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;做了很多事，但回头想不起来今天干了啥&lt;/li&gt;
&lt;li&gt;一天结束的时候没成就感&lt;/li&gt;
&lt;li&gt;事情都是碎片的，很难拼成完整的故事&lt;/li&gt;
&lt;li&gt;想写日报时经常“空白”&lt;/li&gt;
&lt;li&gt;想复盘一下，发现自己根本想不起来&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;MineContext 的价值就在这里：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;它帮你把碎片变成结构。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;白天你专心干活，无脑沉浸。&lt;br&gt;
你不需要特地记，也不需要刻意整理。&lt;/p&gt;
&lt;p&gt;到了晚上，它已经把你的：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;工作流&lt;/li&gt;
&lt;li&gt;切换记录&lt;/li&gt;
&lt;li&gt;查资料轨迹&lt;/li&gt;
&lt;li&gt;输入输出&lt;/li&gt;
&lt;li&gt;项目参与&lt;/li&gt;
&lt;li&gt;日间行为模式&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;全部整理得清清楚楚。&lt;/p&gt;
&lt;p&gt;它甚至会告诉你：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;你今天对某个任务持续投入了 2 小时&lt;/li&gt;
&lt;li&gt;你某段时间反复在查某个问题&lt;/li&gt;
&lt;li&gt;你的注意力什么时候最集中&lt;/li&gt;
&lt;li&gt;你什么时候容易走神&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这对注意力问题人群来说太有用了。&lt;/p&gt;
&lt;p&gt;基本上你只需要“活着”，它帮你记录你的生活&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;这东西意味着什么？&lt;/h2&gt;
&lt;p&gt;我觉得 MineContext 展示了一个&lt;strong&gt;非常新&lt;/strong&gt;的方向：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;AI 正在从“你问我答”，变成“我帮你先做”。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这是一种新工作方式：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;人类负责体验、创造、执行&lt;/li&gt;
&lt;li&gt;AI 负责记录、整理、总结、复盘&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这和我们之前用 AI 的方式完全不一样。&lt;/p&gt;
&lt;p&gt;以前是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;我要你帮我做点事 → 我提供输入 → 你输出&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;MineContext 是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;我在过我的一天 → 它收集输入 → 它输出给我 → 我再决定怎么用&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;这带来的变化非常大：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;你不必靠记忆工作&lt;/li&gt;
&lt;li&gt;你不会错过工作中的关键行为&lt;/li&gt;
&lt;li&gt;你能轻松复盘&lt;/li&gt;
&lt;li&gt;你的大脑不再负责“记”，它只负责“想”&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;甚至我隐约觉得：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;将来老板可能会用这种东西来监控员工……&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;（真的完全不是不可能，毕竟这玩意可以很精确地记录行为模式）&lt;/p&gt;
&lt;p&gt;不过目前我并不是很担心这一方面，毕竟它是开源的，控制权在你。&lt;/p&gt;
&lt;p&gt;而且数据都是存储在本地，并不会上传云端，每次启动也都需要你自己手动开启才可以。&lt;/p&gt;
&lt;p&gt;所以可能老板会更容易从社会学层面入手，比如半夜偷偷溜回公司开你电脑看你一天摸鱼跟朋友八卦的快速总结&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;最后的感受&lt;/h2&gt;
&lt;p&gt;我现在几乎每天晚上都会看它给我生成的日报。&lt;br&gt;
那种体验很奇妙。&lt;/p&gt;
&lt;p&gt;明明是一整天的琐碎行动，它却能被整理成一个“像故事一样”的东西：&lt;br&gt;
有结构、有因果、有线索、有意义。&lt;/p&gt;
&lt;p&gt;它记录的不是任务列表，而是你的节奏、注意力、思考方式、行动轨迹。&lt;br&gt;
感觉真的像是和另一个自己一起观看生活的回放。&lt;/p&gt;
&lt;p&gt;这让我突然意识到：&lt;br&gt;
AI 不是在帮我工作，而是在帮我“理解我自己”。&lt;br&gt;
而理解自己，恰恰是现代人最缺的能力。&lt;/p&gt;
&lt;p&gt;这几年，AI 热潮来得太快了，快到许多人的态度开始走形。&lt;/p&gt;
&lt;p&gt;有人把 AI 神化，觉得它能给人带来超越常识的答案；&lt;br&gt;
有人带着宗教式的期待，把提示词当作咒语，把模型当成神谕；&lt;br&gt;
哪怕一次输出不准，也会怪自己“仪式没做对”，再换一次说法，再祈祷一次。&lt;/p&gt;
&lt;p&gt;那种感觉有点像对未知祈祷：&lt;br&gt;
一半期待救赎，一半害怕怪异。&lt;br&gt;
而在这种矛盾里，人反而越来越看不清自己。&lt;/p&gt;
&lt;p&gt;但我越来越觉得，拒绝 AI 和迷信 AI，本质上都是 &lt;strong&gt;放弃了自己的主体性。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;MineContext 的好在于，它的介入方式非常温和。&lt;br&gt;
它不是来替你做决定的，也不会告诉你应该怎么活。&lt;br&gt;
它只是默默观察、整理、复盘，把信息用你的方式还给你。&lt;/p&gt;
&lt;p&gt;它像一面镜子，但这面镜子比你自己多看到一点点：&lt;br&gt;
看到你忽略的细节、看到你一天当中的节奏、看到你无意间形成的路径。&lt;br&gt;
它帮你把混乱变成秩序，把碎片变成轨迹，把流水账变成有意义的叙事。&lt;/p&gt;
&lt;p&gt;你还是你，只是更清晰了一点。&lt;/p&gt;
&lt;p&gt;在未来这个 AI 会越来越深入环境、工作、家庭，甚至身体的时代里，&lt;br&gt;
我觉得从这种工具开始，让 AI 以一种不侵入的方式参与生活，是一种很好的提前适应。&lt;/p&gt;
&lt;p&gt;你不会迷失自己，也不会落在潮水后面。&lt;/p&gt;
&lt;p&gt;如果你也想体验一下，GitHub 下载下来，填上自己的 API Key，就能直接用：&lt;strong&gt;&lt;a href=&quot;https://github.com/volcengine/MineContext&quot;&gt;MineContext&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;</content:encoded><h:img src="/_astro/ai.D4QuoLDn.png"/><enclosure url="/_astro/ai.D4QuoLDn.png"/></item><item><title>写博客写代码都适用：推完就自动部署的 GitHub Webhook 工具</title><link>https://hejunjie.life/blog/a9ee8nt5</link><guid isPermaLink="true">https://hejunjie.life/blog/a9ee8nt5</guid><description>分享我整理的轻量级 GitHub Webhook 工具，实现博客和代码项目的自动部署，只管写代码或写博客，服务器自动拉取、构建和重启，轻松又高效</description><pubDate>Wed, 19 Nov 2025 16:57:47 GMT</pubDate><content:encoded>&lt;p&gt;前阵子，我看到有人在吐槽自己写博客很麻烦——写麻烦、托管麻烦，推完 GitHub 之后还得去服务器上手动拉代码部署。虽然我以前在做类似的东西，但他这么一提让我想起这件事，我就想着，不如把自己一直随便用的小玩意整理一下，顺便分享给大家。&lt;/p&gt;
&lt;p&gt;于是就有了这个小项目：一个轻量级的 GitHub Webhook Listener，用 Go 写的，可以帮你在推送代码后自动触发部署。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;什么是 Webhook&lt;/h2&gt;
&lt;p&gt;Webhook 本质上就是 GitHub 在某些操作发生时，向你指定的地址发送一个网络通知。最常见的事件包括 &lt;strong&gt;push&lt;/strong&gt;、&lt;strong&gt;pull_request&lt;/strong&gt;、&lt;strong&gt;release&lt;/strong&gt; 等。收到通知之后，你就可以根据事件类型做一些操作，比如自动部署代码、触发测试、更新文档等等。&lt;/p&gt;
&lt;p&gt;设置 Webhook 其实也很简单：在 GitHub 仓库里找到 &lt;strong&gt;Settings → Webhooks → Add webhook&lt;/strong&gt;，填入你服务器的地址和一个 Secret，选中你想监听的事件，然后 GitHub 就会在这些事件发生时发送 POST 请求到你的服务器。只要你的服务端能接收请求、校验 Secret，再执行对应操作，就完成了。&lt;/p&gt;
&lt;p&gt;我以前一直随便把 Webhook 放在某个在跑的服务里处理，所以一直没有好好整理过。Webhook 的核心其实不复杂，它需要的只是一个接口去接收请求，这也是之前一直随手搞的原因。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;为什么选 Go&lt;/h2&gt;
&lt;p&gt;Go 写这种小工具特别合适：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;编译后就是一个二进制文件，直接丢到服务器上运行就行，不依赖其他环境。&lt;/li&gt;
&lt;li&gt;运行开销小，启动快，非常适合长期驻留监听请求。&lt;/li&gt;
&lt;li&gt;写起来简单，核心逻辑就是解析 HTTP 请求、验证签名、匹配事件和分支，然后执行命令。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以我就决定用 Go，把这个小工具整理出来。你只需要放一个简单的配置文件，二进制程序就能运行起来。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;配置示例&lt;/h2&gt;
&lt;p&gt;配置文件大概长这样：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;repos:
  &apos;zxc7563598/astro-theme-pure&apos;: # 仓库名称
    secret: &apos;xxxxxx&apos; # GitHub Webhook Secret
    rules:
      - event: &apos;push&apos;
        branches: [&apos;main&apos;]
        actions:
          - type: &apos;shell&apos;
            command: &apos;sh ./shell/astro-theme-pure.sh&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;程序启动后会在指定端口监听请求，收到事件就去匹配分支和事件类型，如果匹配，就执行你配置好的 shell 脚本。&lt;/p&gt;
&lt;p&gt;就好像上面的配置，当 &lt;strong&gt;zxc7563598/astro-theme-pure&lt;/strong&gt; 仓库的 &lt;strong&gt;main&lt;/strong&gt; 分支触发了 &lt;strong&gt;push&lt;/strong&gt; 时，脚本就会去执行 &lt;code&gt;sh ./shell/astro-theme-pure.sh&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;这样一来，你只管写代码，部署流程就自动完成了。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;我的部署脚本&lt;/h2&gt;
&lt;p&gt;我博客的部署脚本大概是这样：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;#!/bin/bash
set -e
PROJECT_DIR=&quot;/opt/astro-blog&quot;
GIT=&quot;/usr/bin/git&quot;
BUN=&quot;/usr/bin/bun&quot;
PM2=&quot;/usr/bin/pm2&quot;
export HOME=&quot;/root&quot;
export PM2_HOME=&quot;/root/.pm2&quot;

log() {
    echo &quot;[deploy] $1&quot;
}
log &quot;========================================&quot;
log &quot;开始部署 Astro Blog&quot;
log &quot;时间: $(date)&quot;
log &quot;========================================&quot;
cd &quot;$PROJECT_DIR&quot; || { log &quot;无法进入项目目录&quot;; exit 1; }
log &quot;拉取最新代码...&quot;
$GIT fetch origin
$GIT reset --hard origin/main
log &quot;安装依赖（Bun）...&quot;
$BUN install
log &quot;构建 Astro 项目...&quot;
$BUN run build
log &quot;重启 PM2 进程...&quot;
$PM2 restart &quot;$PROJECT_DIR/ecosystem.config.cjs&quot;
log &quot;PM2 状态：&quot;
$PM2 list || true
log &quot;部署完成&quot;
log &quot;时间: $(date)&quot;
log &quot;========================================&quot;

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;有了这个流程，我写博客只管写代码，编辑器里写完 push，一切自动完成。服务器自己去拉取、构建、重启，我几乎不用管。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;如果你也想试试&lt;/h2&gt;
&lt;p&gt;这个小项目我整理在 GitHub 上了：&lt;a href=&quot;https://github.com/zxc7563598/github-webhook-listener&quot;&gt;github-webhook-listener&lt;/a&gt;。它轻量、灵活，上手也很快。如果你也想搞个自动部署，可以直接拿去试试，不用在意具体实现或代码，看看 README，或者直接在 releases 里下载就能用。&lt;/p&gt;</content:encoded><h:img src="/_astro/github.CfLLElkp.jpg"/><enclosure url="/_astro/github.CfLLElkp.jpg"/></item><item><title>自动化我的友链申请脚本：让孤岛互相连起来</title><link>https://hejunjie.life/blog/kdir85h1</link><guid isPermaLink="true">https://hejunjie.life/blog/kdir85h1</guid><description>分享我为个人博客实现的自动化友链申请脚本。通过 SSR 读取 JSON 数据、验证头像与友链页面、自动更新 Git，解决内向者在申请友链时的心理障碍，让孤岛般的博客轻松互相连接。</description><pubDate>Fri, 14 Nov 2025 10:59:33 GMT</pubDate><content:encoded>&lt;p&gt;友链这东西，说出来有点浪漫。&lt;/p&gt;
&lt;p&gt;2025 年了，个人博客基本没有什么流量，更多像是一座座散落在海上的小岛。&lt;br&gt;
偶尔有海风吹过，但大多数时候，就是悄悄发光、自娱自乐。&lt;/p&gt;
&lt;p&gt;而友链……就像是在这些小岛之间铺上一条条细细的桥。&lt;br&gt;
你看不到桥的尽头，但知道那里至少还有一个同样孤独、同样固执的人。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;为什么拖了这么久才开放友链申请？&lt;/h2&gt;
&lt;p&gt;刚写博客那阵子，我其实没太敢申请友链。不是因为不想被发现，而是因为我真的是那种比较内向的类型。&lt;/p&gt;
&lt;p&gt;内容少也不自信，总觉得“我这样去申请友链，会不会显得很冒失？”&lt;br&gt;
再加上每个人对友链的理解都不太一样：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;有人只收特定领域的高质量博客&lt;/li&gt;
&lt;li&gt;有人觉得必须线下认识&lt;/li&gt;
&lt;li&gt;也有人坚持要有一定的线上互动次数&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;即便对方没有列要求，我心里还是会嘀咕：“我们不熟，贸然提出会不会不太礼貌？”&lt;/p&gt;
&lt;p&gt;传统的友链申请方式大多是：留言、邮件，或在 GitHub 提 issue。看似简单，但对我这种“有自助收银台绝不走人工”的 I 人来说，其实是需要一点点勇气的。&lt;/p&gt;
&lt;p&gt;直到有一次，我在看一位老师的博客时，看到有人问：“这么多友链，你是怎么维护的？”&lt;br&gt;
老师回： &lt;strong&gt;“脚本。”&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;那一瞬间我有点被点醒了：&lt;br&gt;
既然我不好意思主动打招呼，那是不是可以先让别人跟我打招呼不要那么困难？&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;说干就干：自动化友链要处理些什么？&lt;/h2&gt;
&lt;p&gt;为了做到“自动添加”，其实需要处理的事情很简单：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;对方网站必须能访问&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;不能让广告站点钻空子&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;对方真得挂上了我的友链&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;于是我要求申请方提供友链页地址，然后脚本负责验证：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;确认主站是正常可访问的&lt;/li&gt;
&lt;li&gt;主站域名与友链页域名一致（避免广告跳转）&lt;/li&gt;
&lt;li&gt;友链页中确实存在我的网站&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;通过就添加，没通过就直接拒绝，简单粗暴但有效。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;技术实现：我是怎么做的？&lt;/h2&gt;
&lt;h3&gt;1. 友链数据剥离成 JSON（重点）&lt;/h3&gt;
&lt;p&gt;一开始我的友链是写在 Astro 的页面里的。&lt;br&gt;
但这意味着：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;改一个字 → 就得重新打包部署&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;非常麻烦。&lt;/p&gt;
&lt;p&gt;后来改用 &lt;strong&gt;SSR（服务端渲染）&lt;/strong&gt;  后，我意识到：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;完全没必要把友链打包进页面里。&lt;br&gt;
数据完全可以在服务端“即时读取”。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;于是我把友链抽成一个 &lt;code&gt;links.json&lt;/code&gt; 文件，并且 &lt;strong&gt;不再使用&lt;/strong&gt; &lt;strong&gt;​&lt;code&gt;import&lt;/code&gt;​&lt;/strong&gt; &lt;strong&gt;引入它&lt;/strong&gt;。&lt;br&gt;
因为一旦 import，它就会在构建时被写死。&lt;/p&gt;
&lt;p&gt;正确的方式是：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const raw = await fs.readFile(linksPath, &apos;utf-8&apos;)
const { friends } = JSON.parse(raw)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;JSON 是独立的数据源&lt;/li&gt;
&lt;li&gt;SSR 每次渲染页面都会读取到最新内容&lt;/li&gt;
&lt;li&gt;修改 JSON = 友链立即生效&lt;/li&gt;
&lt;li&gt;无需重新打包&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这也是我把友链从页面剥离出来的根本原因。&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;2. 头像检查 + 上传 OSS&lt;/h3&gt;
&lt;p&gt;用户提交的头像链接永远无法保证靠谱：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;有的不是图片&lt;/li&gt;
&lt;li&gt;有的没 content-type&lt;/li&gt;
&lt;li&gt;有的会失效&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;我的解决方案是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;先判断扩展名是否是图片&lt;/li&gt;
&lt;li&gt;再通过请求检查 content-type&lt;/li&gt;
&lt;li&gt;最后统一上传到 OSS&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;我用的是 &lt;code&gt;ossutil&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;因为它是 CLI，不需要把 AccessKey 写进代码里，对我这种 Node 不熟练的人来说更安全。&lt;/p&gt;
&lt;p&gt;（当然也可以不用 OSS，不过不管是速度还是稳定性都不如 OSS 省心）&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;3. 检查对方是否挂了我的链接&lt;/h3&gt;
&lt;p&gt;为了避免“我挂你，你不挂我”或者广告的情况出现，脚本会：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;访问对方主站&lt;/li&gt;
&lt;li&gt;检查友链页是否同域&lt;/li&gt;
&lt;li&gt;抓取页面，看是否包含我的链接&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;确认通过，才会添加。&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;4. 自动更新 JSON + 顺手 Git 提交&lt;/h3&gt;
&lt;p&gt;友链通过验证后，脚本会：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;把条目写入 &lt;code&gt;links.json&lt;/code&gt;​&lt;/li&gt;
&lt;li&gt;自动执行 &lt;code&gt;git add → commit → push&lt;/code&gt;​&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;因为是 SSR，页面下一次请求时就会看到最新的友链数据。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;不需要重新打包，也不需要重新部署。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;整个流程非常轻量。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;SSR 这个环节的重要性&lt;/h2&gt;
&lt;p&gt;在写脚本之前，我也想过纯静态博客能不能实现自动化友链。&lt;br&gt;
答案是：&lt;strong&gt;能，但非常折腾，不值得。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;纯静态的问题：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;页面打包后就是死的，无法在服务端执行校验逻辑&lt;/li&gt;
&lt;li&gt;用户无法直接让服务器写入文件&lt;/li&gt;
&lt;li&gt;想走 GitHub Actions，需要用户先有权限 push（显然不行）&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;虽然理论上可以：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;表单提交 → 云函数写文件 → 云函数 push GitHub → Actions 构建 → 部署&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;但整个链路太长太容易出问题。&lt;/p&gt;
&lt;p&gt;相比之下：&lt;/p&gt;
&lt;h3&gt;&lt;strong&gt;SSR：写一个 API 就全部搞定&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;Cloudflare、Vercel 都能跑 SSR&lt;br&gt;
甚至没服务器也能跑&lt;/p&gt;
&lt;p&gt;所以最后我还是选择了最干净、最好理解、扩展性最高的方案：&lt;br&gt;
&lt;strong&gt;在 SSR 里加一个接口，让它帮我跑脚本、改 JSON、推 Git 就完事了。&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;最后的一点小感慨&lt;/h2&gt;
&lt;p&gt;整个自动化流程，说白了，就是给像我这样的 I 人一点点缓冲空间。&lt;/p&gt;
&lt;p&gt;我非常尊重那些坚持手工审核友链的朋友&lt;/p&gt;
&lt;p&gt;那是对自己博客节奏和边界的坚持，我完全理解。&lt;/p&gt;
&lt;p&gt;但我也知道，有不少朋友可能跟我一样：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;觉得对方的博客不错&lt;/li&gt;
&lt;li&gt;想互相交换友链&lt;/li&gt;
&lt;li&gt;却迟迟按不下那个“开口”的按钮&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这个脚本解决不了我鼓起勇气去申请别人友链的问题。&lt;/p&gt;
&lt;p&gt;但至少 &lt;strong&gt;如果你也是这样的 I 人，来我的博客交换友链时，你完全可以毫无心理负担&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;如果你想看完整的代码与改动，可以看这里：&lt;br&gt;
👉 &lt;a href=&quot;https://github.com/zxc7563598/astro-theme-pures/commit/9f649a8a4f03d56ad67609776f3dc4adc5458aef&quot;&gt;点击查看&lt;/a&gt;&lt;/p&gt;</content:encoded><h:img src="/_astro/web.n3Pk-HlC.jpg"/><enclosure url="/_astro/web.n3Pk-HlC.jpg"/></item><item><title>被问性能后，我封装了这个 PHP 错误上报工具</title><link>https://hejunjie.life/blog/sd84uthv</link><guid isPermaLink="true">https://hejunjie.life/blog/sd84uthv</guid><description>介绍 PHP 日志库 hejunjie/lazylog 的实现：用 proc_open / exec 伪异步上报异常，支持本地日志与常驻内存框架优化，轻量高效，适合生产环境使用</description><pubDate>Tue, 11 Nov 2025 15:34:05 GMT</pubDate><content:encoded>&lt;p&gt;最近我把自己常用的一套错误上报逻辑封装成了一个 Composer 包，叫 &lt;strong&gt;​&lt;code&gt;hejunjie/lazylog&lt;/code&gt;​&lt;/strong&gt;。&lt;br&gt;
功能很简单也很实用：&lt;strong&gt;安全地写本地日志 + 把异常信息上报到远端（支持同步/异步）&lt;/strong&gt; 。本文讲讲为什么我要做这个库、实现思路、在不同运行环境下如何选择（以及我推荐的优化方案）。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;起因：为啥要做这个工具？&lt;/h2&gt;
&lt;p&gt;先讲个背景。之前我写了一个 Go 项目 —— &lt;a href=&quot;https://github.com/zxc7563598/oh-shit-logger&quot;&gt;oh-shit-logger&lt;/a&gt;，目标是把不同语言、不同项目里的错误集中收集到一个地方。Go 做服务天然快、部署也简单： GitHub Actions自动打包，我只要把包丢主机上一键启动就好了。&lt;/p&gt;
&lt;p&gt;但上线后有朋友问：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;“PHP 上报错误会不会太耗性能？网络 I/O 会不会成为瓶颈？”&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;这是个很合理的问题。网络 I/O 的确有成本，但异常本身在多数系统里不是那种持续不断、高频率的事件（如果异常多到经常并发，那系统可能已经在出问题了）。&lt;/p&gt;
&lt;p&gt;与其空谈“会不会慢”，我更愿意把常用做法封装一下，直接给出一个实战好用的方案——于是 &lt;code&gt;hejunjie/lazylog&lt;/code&gt; 诞生了。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;思路概览：伪异步 + 可回退的同步&lt;/h2&gt;
&lt;p&gt;​&lt;code&gt;lazylog&lt;/code&gt; 的核心思路很简单：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;本地写日志&lt;/strong&gt;：线程安全、支持按行数/大小自动切分，长期运行不会把单个日志文件撑爆。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;远程上报&lt;/strong&gt;：提供两种方式：
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;异步上报（伪异步）&lt;/strong&gt; ：通过 &lt;code&gt;proc_open()&lt;/code&gt; 或 &lt;code&gt;exec()&lt;/code&gt; fork 出一个 PHP CLI 子进程来发送 HTTP POST，不阻塞主进程。适用于 PHP-FPM、一次性 CLI 脚本等短生命周期环境。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;同步上报&lt;/strong&gt;：直接在当前进程做一个带超时的 HTTP POST，适合常驻内存框架（Webman、Swoole、RoadRunner 等）或需要保证上报结果的场景。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;我把这些行为都封装在一个很小的包里：&lt;code&gt;composer require hejunjie/lazylog&lt;/code&gt;，在任何项目里都能快速复用。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;异步实现细节：为什么是“伪异步”？&lt;/h2&gt;
&lt;p&gt;PHP 没有内置线程（除非用扩展），但我们可以通过子进程实现“非阻塞式”的上报：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;​&lt;code&gt;proc_open()&lt;/code&gt;：启动子进程并可拿到 stdin/stdout/stderr，控制能力强；但会创建管道资源，需要注意关闭管道以免资源泄露。&lt;/li&gt;
&lt;li&gt;​&lt;code&gt;exec()&lt;/code&gt;：简单粗暴，把命令交给 shell 去做 &lt;code&gt;fork&lt;/code&gt;，父进程可立即返回（命令后面加 &lt;code&gt;&amp;#x26;&lt;/code&gt;）。语义上更轻量，但控制能力弱。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;两者的本质都是 fork 一个新进程去跑 PHP CLI，然后子进程读取临时文件（或者接收传参）、发 POST、删临时文件、退出。主进程不会等子进程走完就返回给用户，所以对用户体验几乎零影响。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;优点&lt;/strong&gt;：实现简单、跨平台、即插即用；适合错误信息本身不高频的场景。&lt;br&gt;
&lt;strong&gt;缺点&lt;/strong&gt;：在“极高并发”场景下（比如每秒上千条错误）会比较吃资源，子进程启动和网络请求仍然有成本。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;常驻内存框架（Webman/Swoole）该怎么办？&lt;/h2&gt;
&lt;p&gt;这是个重要的实践问题：&lt;strong&gt;在常驻内存框架中，我更推荐用同步上报或队列，而不是频繁 fork 子进程。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;原因很直观：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;常驻框架的 Worker 是长期存在的，fork 子进程会带来额外的资源管理问题（僵尸进程、内存增长、文件描述符等）。&lt;/li&gt;
&lt;li&gt;同步上报虽然会阻塞当前 Worker，但只影响当前 Worker，不会像在传统短生命周期中影响整个请求模型。对于大多数低频异常而言，这个阻塞代价是可以接受的。&lt;/li&gt;
&lt;li&gt;更稳妥的做法是：&lt;strong&gt;把异常先格式化成数组，投递到队列，由专门的队列 worker 来异步上报&lt;/strong&gt;。这样既避免了直接 fork，又能在不影响主流程的情况下批量/可靠地上报。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;我在包里同时提供了 &lt;code&gt;reportSync()&lt;/code&gt;（同步上报）和 &lt;code&gt;reportAsync()&lt;/code&gt;（伪异步上报），并提供 &lt;code&gt;Logger::formatThrowable()&lt;/code&gt; 帮你把异常转成纯数据结构，方便推队列或序列化。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;实际使用示例&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;这里只放伪代码以示意，实际代码见仓库。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;strong&gt;本地写日志&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;Logger::write(&apos;/var/logs&apos;, &apos;error/app.log&apos;, &apos;Task Failed&apos;, [&apos;msg&apos; =&gt; &apos;something wrong&apos;]);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;短生命周期场景（异步上报）&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;try {
  // ...
} catch (Throwable $e) {
  Logger::reportAsync($e, &apos;https://your-collector/collect&apos;, &apos;my-project&apos;);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;常驻框架（推荐同步或队列）&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;try {
  // ...
} catch (Throwable $e) {
  // 同步上报（简单、直接）
  Logger::reportSync($e, &apos;https://your-collector/collect&apos;, &apos;my-project&apos;);

  // 或者：转成数组，投递队列，由 Worker 负责上报（推荐）
  $payload = Logger::formatThrowable($e, &apos;my-project&apos;);
  Queue::push(&apos;error_report&apos;, $payload);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;性能那些事儿&lt;/h2&gt;
&lt;p&gt;有人担心“网络 I/O 会把 PHP 卡死”。我的观点是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;错误本身通常是低频事件&lt;/strong&gt;。如果你的系统错误频率高到持续占用大量带宽/请求，那说明系统正常运行已经有更严重的问题了。&lt;/li&gt;
&lt;li&gt;对于多数业务，&lt;strong&gt;一次 fork 一个子进程并做一次 HTTP POST 的开销在可接受范围&lt;/strong&gt;，用户体验影响极小。&lt;/li&gt;
&lt;li&gt;在对性能要求极苛刻或错误量非常大的场景，正确做法是&lt;strong&gt;把上报变成队列 + 批量发送&lt;/strong&gt;或将上报移动到专门的后端处理链路，而不是在业务路径里频繁 fork。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;总之：&lt;strong&gt;衡量利弊后选择适合你业务的方式&lt;/strong&gt;。&lt;code&gt;lazylog&lt;/code&gt; 提供了两端（sync/async）以及格式化功能，方便你按需设计。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;最后&lt;/h2&gt;
&lt;p&gt;我把它做成 composer 包的原因很直接：我希望 &lt;strong&gt;快速把 PHP 项目的错误上报到我自己的 Go 服务（oh-shit-logger）&lt;/strong&gt; ，而不是每个项目都重复造轮子。把常用逻辑抽出来，项目里 &lt;code&gt;composer require hejunjie/lazylog&lt;/code&gt; 就能统一上报方式——既省事又稳妥。&lt;/p&gt;
&lt;p&gt;如果你想快速了解这个项目：&lt;a href=&quot;https://zread.ai/zxc7563598/php-lazylog&quot;&gt;Zread 解析文档&lt;/a&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;如果你是在 &lt;strong&gt;PHP-FPM / CLI&lt;/strong&gt; 的短生命周期环境：&lt;code&gt;reportAsync()&lt;/code&gt; 很方便，能保证主流程不被阻塞。&lt;/li&gt;
&lt;li&gt;如果你是在 &lt;strong&gt;Webman/Swoole 等常驻内存框架&lt;/strong&gt;：优先考虑 &lt;code&gt;reportSync()&lt;/code&gt; 或推队列再上报。&lt;/li&gt;
&lt;li&gt;如果你面临的是&lt;strong&gt;极高并发的错误量&lt;/strong&gt;：把上报放队列，批量发送，或交由专门的采集基础设施处理。&lt;/li&gt;
&lt;/ul&gt;</content:encoded><h:img src="/_astro/php.DoApYH8-.png"/><enclosure url="/_astro/php.DoApYH8-.png"/></item><item><title>Astro 博客加密教程：保护文章内容的 SSR 方案</title><link>https://hejunjie.life/blog/idR42daq</link><guid isPermaLink="true">https://hejunjie.life/blog/idR42daq</guid><description>静态博客的文章加密其实并不简单。本文分享我在 Astro 博客中实现文章加密的完整思路，从静态输出改为 SSR，通过接口验证实现安全、灵活的内容访问控制。</description><pubDate>Sun, 09 Nov 2025 16:31:02 GMT</pubDate><content:encoded>&lt;h2&gt;为什么想要加密文章&lt;/h2&gt;
&lt;p&gt;有时候我们希望自己的博客文章不是所有人都能直接看到，最好能设置一个问题验证——只有答对的人才能解锁内容。&lt;br&gt;
听起来简单，但对于&lt;strong&gt;纯静态博客&lt;/strong&gt;来说，这其实挺麻烦的。&lt;/p&gt;
&lt;h2&gt;静态博客的“加密”假象&lt;/h2&gt;
&lt;p&gt;静态博客的所有内容，在构建时就已经写进了 HTML 里。&lt;br&gt;
即使你用 CSS 或 JavaScript 把文字“藏”起来，用户打开浏览器开发者工具就能轻松看到原文。&lt;/p&gt;
&lt;p&gt;有人会选择在前端加密文章内容，然后在浏览器里解密再显示。&lt;br&gt;
这个办法能提高一点门槛，但问题是——&lt;strong&gt;加密密钥也在前端&lt;/strong&gt;，意味着别人仍有办法找到它。&lt;/p&gt;
&lt;p&gt;真正安全的做法，不是去“隐藏内容”，而是&lt;strong&gt;让验证和解密都发生在服务器上&lt;/strong&gt;，浏览器只负责展示。&lt;/p&gt;
&lt;h2&gt;SSR：给静态博客加上“动态大脑”&lt;/h2&gt;
&lt;p&gt;当博客切换到服务端渲染（SSR）后，文章内容就不会提前打包到前端，而是在请求时由服务器生成。&lt;/p&gt;
&lt;p&gt;这样做的好处显而易见：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;内容不再暴露在 HTML 里&lt;/li&gt;
&lt;li&gt;可以在服务端验证访问条件&lt;/li&gt;
&lt;li&gt;访问控制逻辑更清晰、可扩展&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以我的思路是：用 &lt;strong&gt;SSR + 接口验证&lt;/strong&gt; 的方式，实现文章加密。&lt;/p&gt;
&lt;h2&gt;实现思路概览&lt;/h2&gt;
&lt;p&gt;我把整个流程拆成了几个部分来实现，使用的主题为：&lt;strong&gt;astro-theme-pure&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;其余的主题依然可以用本文思路实现&lt;/p&gt;
&lt;h3&gt;1. 从静态输出改为 SSR&lt;/h3&gt;
&lt;p&gt;将 &lt;code&gt;astro.config.ts&lt;/code&gt; 里的 &lt;code&gt;output: &apos;static&apos;&lt;/code&gt; 改为 &lt;code&gt;output: &apos;server&apos;&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;在我使用的 astro-theme-pure 主题的配置文件中已经预设了对应的配置，其余 &lt;code&gt;astro&lt;/code&gt; 的配置大抵相同&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;export default defineConfig({
  // Adapter
  // https://docs.astro.build/en/guides/deploy/
  // 1. Vercel (serverless)
  // adapter: vercel(),
  // output: &apos;server&apos;,
  // 2. Vercel (static)
  // adapter: vercel(),
  // output: &apos;static&apos;,
  // 3. Local (standalone)
  adapter: node({ mode: &apos;standalone&apos; }),
  output: &apos;server&apos;,
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;他们代表了博客的3种部署方式&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Vercel (serverless)&lt;/strong&gt;&lt;br&gt;
SSR（服务端渲染）模式。&lt;br&gt;
每次访问时才执行渲染，不访问就不占资源。适合部署在托管平台上。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Vercel (static)&lt;/strong&gt;&lt;br&gt;
SSG（静态渲染）模式。&lt;br&gt;
所有页面在构建阶段生成，访问速度快但无法动态控制内容。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Local (standalone)&lt;/strong&gt;&lt;br&gt;
SSR（服务端渲染）模式。&lt;br&gt;
会生成一个可直接运行的 Node 服务，适合自己托管。&lt;br&gt;
不过是长驻进程，不像 serverless 那样按需启动。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;两种 SSR 模式的主要区别在于是否支持长时间运行的任务（大多数 serverless 函数会有 10 秒左右的超时限制）。&lt;br&gt;
对于文章加密这类轻量功能，两种都完全够用。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;如果 standalone 模式报缺少 &lt;code&gt;node&lt;/code&gt; 模块，安装 &lt;code&gt;@astrojs/node&lt;/code&gt; 即可。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;2. Frontmatter 增加加密字段&lt;/h3&gt;
&lt;p&gt;在每篇文章的 frontmatter 中增加 &lt;code&gt;password&lt;/code&gt; 字段，用来存储文章的“问题与答案”：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;---
title: &quot;加密文章示例&quot;
publishDate: &quot;2025-11-09&quot;
password: 
  - { question: &apos;访问密码是什么（123456）&apos;, answer: &apos;123456&apos; }
  - { question: &apos;可以设置多个问题吗（可以）&apos;, answer: &apos;可以&apos; }
---
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在我使用的 astro-theme-pure 主题预设了博客文章的 frontmatter 字段，在 &lt;code&gt;src/content.config.ts&lt;/code&gt; 中，其余 &lt;code&gt;astro&lt;/code&gt; 主题大抵相同，需要自行处理&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// Define blog collection
const blog = defineCollection({
  // Load Markdown and MDX files in the `src/content/blog/` directory.
  loader: glob({ base: &apos;./src/content/blog&apos;, pattern: &apos;**/*.{md,mdx}&apos; }),
  // Required
  schema: ({ image }) =&gt;
    z.object({
      // Required
      title: z.string().max(60),
      description: z.string().max(160),
      publishDate: z.coerce.date(),
      // Optional
      updatedDate: z.coerce.date().optional(),
      heroImage: z
        .object({
          src: image(),
          alt: z.string().optional(),
          inferSize: z.boolean().optional(),
          width: z.number().optional(),
          height: z.number().optional(),
          color: z.string().optional()
        })
        .optional(),
      tags: z.array(z.string()).default([]).transform(removeDupsAndLowerCase),
      language: z.string().optional(),
      draft: z.boolean().default(false),
      // Special fields
      comment: z.boolean().default(true),
      // 增加 password
      password: z.array(
          z.object({
            question: z.string().min(1, &apos;问题不能为空&apos;),
            answer: z.string().min(1, &apos;答案不能为空&apos;)
          })
        ).default([])
    })
})
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3. 调整文章渲染方式&lt;/h3&gt;
&lt;p&gt;在原先的 Astro 博客中，文章页面是在 &lt;code&gt;src/pages/blog/[...id].astro&lt;/code&gt; 这样渲染的：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import { render, type CollectionEntry } from &apos;astro:content&apos;

import { getBlogCollection, sortMDByDate } from &apos;astro-pure/server&apos;
import PostLayout from &apos;@/layouts/BlogPost.astro&apos;

export const prerender = true

export async function getStaticPaths() {
  const posts = sortMDByDate(await getBlogCollection())
  return posts.map((post) =&gt; ({
    params: { id: post.id },
    props: { post, posts }
  }))
}

type Props = {
  post: CollectionEntry&amp;#x3C;&apos;blog&apos;&gt;
  posts: CollectionEntry&amp;#x3C;&apos;blog&apos;&gt;[]
}

const { post, posts } = Astro.props
const { Content, headings, remarkPluginFrontmatter } = await render(post)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这种方式属于&lt;strong&gt;静态生成（SSG）&lt;/strong&gt; ：&lt;/p&gt;
&lt;p&gt;在构建阶段（&lt;code&gt;astro build&lt;/code&gt;），Astro 会：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;执行 &lt;code&gt;getStaticPaths()&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;拿到所有文章（posts）并生成一个“路径列表”。&lt;/li&gt;
&lt;li&gt;对列表中的每个路径都提前生成一个 HTML 文件（比如 &lt;code&gt;/blog/xxx/index.html&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;把 &lt;code&gt;post&lt;/code&gt; 数据写进这个静态文件里。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;而我们并不想让文章内容被写入到 &lt;code&gt;html&lt;/code&gt; 中，而是动态渲染文章内容，我们就是为此才切换到 SSR 模式，在 SSR 模式下：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;你不能再用 &lt;code&gt;getStaticPaths&lt;/code&gt; 去“预生成”页面。&lt;/li&gt;
&lt;li&gt;​&lt;code&gt;Astro.props&lt;/code&gt; 也不会有 &lt;code&gt;post&lt;/code&gt;、&lt;code&gt;posts&lt;/code&gt; 这种预注入的数据（因为 SSR 时没有提前构建这些 props）。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以我们需要调整 &lt;code&gt;src/pages/blog/[...id].astro&lt;/code&gt; 为：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import { render, type CollectionEntry } from &apos;astro:content&apos;

import { getBlogCollection, sortMDByDate } from &apos;astro-pure/server&apos;
import PostLayout from &apos;@/layouts/BlogPost.astro&apos;

export const prerender = false

type Props = {
  post: CollectionEntry&amp;#x3C;&apos;blog&apos;&gt;
  posts: CollectionEntry&amp;#x3C;&apos;blog&apos;&gt;[]
}

const { id } = Astro.params
const allPosts = await getBlogCollection()
const posts = sortMDByDate(
  allPosts.filter((p) =&gt; p.collection === &apos;blog&apos;)
) as CollectionEntry&amp;#x3C;&apos;blog&apos;&gt;[]
const post = posts.find((p) =&gt; p.id === id)
if (!post) {
  throw new Error(`Blog post not found: ${id}`)
}

const { Content, headings, remarkPluginFrontmatter } = await render(post)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;4. 前端交互：Vue 组件&lt;/h3&gt;
&lt;p&gt;创建一个 Vue 小组件：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;只显示问题，不知道答案&lt;/li&gt;
&lt;li&gt;用户输入答案后提交给接口&lt;/li&gt;
&lt;li&gt;根据接口响应刷新页面或者提示错误&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这个组件可以在任何页面使用，因此不做过多描述，在你的项目中任意目录创建一个 &lt;code&gt;vue&lt;/code&gt; 文件，之后在需要使用的 &lt;code&gt;.astro&lt;/code&gt; 文件中通过 &lt;code&gt;import&lt;/code&gt; 引用即可&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-vue&quot;&gt;&amp;#x3C;template&gt;
    &amp;#x3C;div
        class=&quot;relative mb-8 pl-4 border-l-2 border-foreground/10 text-left text-sm sm:text-base text-muted-foreground leading-relaxed&quot;&gt;
        当前文章为加密文章&amp;#x3C;br /&gt;
        请回答问题以获取查看文章的权限
    &amp;#x3C;/div&gt;
    &amp;#x3C;div class=&quot;grid gap-3.5 sm:grid-cols-1 sm:gap-4 lg:grid-cols-2 [&amp;#x26;&gt;*:only-child]:lg:col-span-2&quot;&gt;
        &amp;#x3C;div class=&quot;not-prose block relative rounded-2xl border px-5 py-3 transition-all hover:border-foreground/25 hover:shadow-sm cursor-pointer&quot;
            v-for=&quot;(item, index) in questions&quot; :key=&quot;index&quot;&gt;
            &amp;#x3C;div class=&quot;flex flex-col gap-y-1.5&quot;&gt;
                &amp;#x3C;div class=&quot;flex flex-col gap-y-0.5&quot;&gt;
                    &amp;#x3C;h2 class=&quot;text-lg font-medium&quot;&gt;问题{{ index + 1 }}&amp;#x3C;/h2&gt;
                    &amp;#x3C;p class=&quot;text-muted-foreground&quot;&gt;{{ item }}&amp;#x3C;/p&gt;
                &amp;#x3C;/div&gt;
                &amp;#x3C;div&gt;
                    &amp;#x3C;input v-model=&quot;answers[index]&quot; type=&quot;text&quot;
                        class=&quot;flex-1 w-full rounded-lg border px-3 py-2 text-sm outline-none focus:ring-1&quot; /&gt;
                &amp;#x3C;/div&gt;
            &amp;#x3C;/div&gt;
        &amp;#x3C;/div&gt;
    &amp;#x3C;/div&gt;
    &amp;#x3C;div class=&quot;relative w-full text-end mt-3.5&quot;&gt;
        &amp;#x3C;button
            class=&quot;rounded-lg bg-muted px-8 py-2 text-muted-foreground text-sm hover:bg-card transition&quot;
            :disabled=&quot;loading&quot; @click=&quot;verifyAnswer&quot;&gt;
            {{ loading ? &apos;验证中...&apos; : &apos;验证&apos; }}
        &amp;#x3C;/button&gt;
    &amp;#x3C;/div&gt;
&amp;#x3C;/template&gt;

&amp;#x3C;script setup lang=&quot;ts&quot;&gt;
import { reactive, ref } from &apos;vue&apos;;
import { showToast } from &apos;@/plugins/toast&apos;

const loading = ref(false);

const props = defineProps&amp;#x3C;{
    slug: string;
    questions: string[];
}&gt;();

const answers = reactive&amp;#x3C;string[]&gt;(Array(props.questions.length).fill(&apos;&apos;));

async function verifyAnswer() {
    loading.value = true
    const path = window.location.pathname
    const formData = new FormData()
    answers.forEach((a, i) =&gt; formData.append(`answer_${i}`, a))
    formData.append(`path`, path)
    const res = await fetch(`/api/verify?slug=${props.slug}`, {
        method: &apos;POST&apos;,
        body: formData,
        credentials: &apos;same-origin&apos;,
    });
    const data = await res.json()
    if (res.ok &amp;#x26;&amp;#x26; data.success) {
        window.location.reload()
    } else {
        showToast({ message: data.error || &apos;验证失败&apos; })
    }
    loading.value = false
}
&amp;#x3C;/script&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;5. 后端接口：验证问题答案&lt;/h3&gt;
&lt;p&gt;用一个 TypeScript 文件实现验证接口：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;通过 &lt;code&gt;astro:content&lt;/code&gt; 获取文章 frontmatter。&lt;/li&gt;
&lt;li&gt;对比用户提交的答案和正确答案。&lt;/li&gt;
&lt;li&gt;如果验证成功，设置加密 cookie，并刷新页面显示文章内容。&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import crypto from &apos;crypto&apos;
import type { APIRoute } from &apos;astro&apos;
import { getCollection } from &apos;astro:content&apos;

const SECRET =
  process.env.COOKIE_SECRET || &apos;q1W2e3R4t5Y6u7I8o9P0aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789-_&apos;

// 用 HMAC 签名
function sign(value: string) {
  return crypto.createHmac(&apos;sha256&apos;, SECRET).update(value).digest(&apos;base64url&apos;)
}

// 生成签名 cookie 值
function makeSignedCookieValue(slug: string, maxAgeSec = 86400) {
  const expires = Math.floor(Date.now() / 1000) + maxAgeSec
  const payload = `${slug}:${expires}`
  const sig = sign(payload)
  return `${payload}:${sig}`
}

// 验证签名 cookie 值
export function verifySignedCookieValue(cookieValue: string | null | undefined) {
  if (!cookieValue) return false
  const parts = cookieValue.split(&apos;:&apos;)
  if (parts.length !== 3) return false
  const [slug, expiresStr, sig] = parts
  const payload = `${slug}:${expiresStr}`
  const expectedSig = sign(payload)
  if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig))) return false
  if (parseInt(expiresStr, 10) &amp;#x3C; Math.floor(Date.now() / 1000)) return false
  return slug
}

export const POST: APIRoute = async ({ request, url }) =&gt; {
  const slug = new URL(url).searchParams.get(&apos;slug&apos;)
  if (!slug)
    return new Response(JSON.stringify({ error: &apos;非法请求&apos; }), {
      status: 400,
      headers: { &apos;Content-Type&apos;: &apos;application/json&apos; }
    })
  const posts = await getCollection&amp;#x3C;&apos;blog&apos;&gt;(&apos;blog&apos;)
  const post = posts.find((p) =&gt; p.id === slug)
  if (!post)
    return new Response(JSON.stringify({ error: &apos;文章不存在&apos; }), {
      status: 404,
      headers: { &apos;Content-Type&apos;: &apos;application/json&apos; }
    })
  const formData = await request.formData()
  const password = post.data.password || []
  const correct = password.every((p, i) =&gt; {
    const answer = formData.get(`answer_${i}`)?.toString().trim().toLowerCase()
    return answer === p.answer.trim().toLowerCase()
  })
  if (!correct) {
    return new Response(JSON.stringify({ error: &apos;答案错误&apos; }), {
      status: 401,
      headers: { &apos;Content-Type&apos;: &apos;application/json&apos; }
    })
  }
  const path = formData.get(`path`)?.toString().trim()
  const cookieVal = makeSignedCookieValue(slug, 24 * 3600)
  const headers = new Headers()
  headers.append(
    &apos;Set-Cookie&apos;,
    `verified-${slug}=${cookieVal}; Path=${path}; HttpOnly; Secure; SameSite=Lax; Max-Age=86400`
  )
  return new Response(JSON.stringify({ success: true }), {
    status: 200,
    headers: {
      &apos;Content-Type&apos;: &apos;application/json&apos;,
      &apos;Set-Cookie&apos;: `verified-${slug}=${cookieVal}; Path=${path}; HttpOnly; Secure; SameSite=Lax; Max-Age=86400`
    }
  })
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;6. 改造文章模板&lt;/h3&gt;
&lt;p&gt;在文章模板中增加逻辑：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;检查文章是否加密。&lt;/li&gt;
&lt;li&gt;检查 cookie 是否存在且有效。&lt;/li&gt;
&lt;li&gt;如果未验证，显示 Vue 加密组件。&lt;/li&gt;
&lt;li&gt;验证通过，显示文章内容。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;在 &lt;code&gt;src/layouts/BlogPost.astro&lt;/code&gt; 中进行调整：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-astro&quot;&gt;---
import type { MarkdownHeading } from &apos;astro&apos;
import type { CollectionEntry } from &apos;astro:content&apos;

// Plugin styles
import &apos;katex/dist/katex.min.css&apos;

import { MediumZoom } from &apos;astro-pure/advanced&apos;
import { ArticleBottom, Hero } from &apos;astro-pure/components/pages&apos;
import PageLayout from &apos;@/layouts/ContentLayout.astro&apos;
import { verifySignedCookieValue } from &apos;@/pages/api/verify&apos;
import Copyright from &apos;@/components/custom/Copyright.astro&apos;
import TOC from &apos;@/components/custom/TOC.astro&apos;
import PasswordForm from &apos;@/components/vue/PasswordForm.vue&apos;
import { Comment, PageInfo } from &apos;@/components/waline&apos;
import { integ } from &apos;@/site-config&apos;

interface Props {
  post: CollectionEntry&amp;#x3C;&apos;blog&apos;&gt;
  posts: CollectionEntry&amp;#x3C;&apos;blog&apos;&gt;[]
  headings: MarkdownHeading[]
  remarkPluginFrontmatter: Record&amp;#x3C;string, unknown&gt;
}

const {
  post: { id, data },
  posts,
  headings,
  remarkPluginFrontmatter
} = Astro.props

const {
  description,
  heroImage,
  publishDate,
  title,
  updatedDate,
  draft: isDraft,
  comment: enableComment
} = data

const socialImage = heroImage
  ? typeof heroImage.src === &apos;string&apos;
    ? heroImage.src
    : heroImage.src.src
  : &apos;/images/social-card.png&apos;
const articleDate = updatedDate?.toISOString() ?? publishDate.toISOString()
const primaryColor = data.heroImage?.color ?? &apos;hsl(var(--primary) / var(--un-text-opacity))&apos;

// 读取 cookie 并验证
const cookieHeader = Astro.request.headers.get(&apos;cookie&apos;)
let verified = false
if (cookieHeader) {
  const cookies = Object.fromEntries(
    cookieHeader.split(&apos;;&apos;).map((s) =&gt; {
      const [k, v] = s.trim().split(&apos;=&apos;)
      return [k, v]
    })
  )
  const result = verifySignedCookieValue(cookies[`verified-${id}`])
  if (result &amp;#x26;&amp;#x26; result === id) verified = true
}

const password = data.password || []
const questions = password.map((p) =&gt; p.question)
if (questions.length == 0) {
  verified = true
}
---

&amp;#x3C;PageLayout
  meta={{ articleDate, description, ogImage: socialImage, title }}
  highlightColor={primaryColor}
  back=&apos;/blog&apos;
&gt;
  {verified &amp;#x26;&amp;#x26; !!headings.length &amp;#x26;&amp;#x26; &amp;#x3C;TOC {headings} slot=&apos;sidebar&apos; /&gt;}

  &amp;#x3C;Hero {data} {remarkPluginFrontmatter} slot=&apos;header&apos;&gt;
    &amp;#x3C;Fragment slot=&apos;description&apos;&gt;
      {!isDraft &amp;#x26;&amp;#x26; enableComment &amp;#x26;&amp;#x26; &amp;#x3C;PageInfo comment class=&apos;mt-1&apos; /&gt;}
    &amp;#x3C;/Fragment&gt;
  &amp;#x3C;/Hero&gt;

  {verified ? &amp;#x3C;slot /&gt; : &amp;#x3C;PasswordForm slug={id} questions={questions} client:load /&gt;}

  &amp;#x3C;Fragment slot=&apos;bottom&apos;&gt;
    {/* Copyright */}
    &amp;#x3C;Copyright {data} /&gt;
    {/* Article recommend */}
    &amp;#x3C;ArticleBottom collections={posts} {id} class=&apos;mt-3 sm:mt-6&apos; /&gt;
    {/* Comment */}
    {!isDraft &amp;#x26;&amp;#x26; enableComment &amp;#x26;&amp;#x26; &amp;#x3C;Comment class=&apos;mt-3 sm:mt-6&apos; /&gt;}
  &amp;#x3C;/Fragment&gt;

  &amp;#x3C;slot name=&apos;bottom-sidebar&apos; slot=&apos;bottom-sidebar&apos; /&gt;
&amp;#x3C;/PageLayout&gt;

{integ.mediumZoom.enable &amp;#x26;&amp;#x26; &amp;#x3C;MediumZoom /&gt;}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;总结&lt;/h2&gt;
&lt;p&gt;通过 SSR + 接口验证的方式，我们可以在 Astro 博客中实现比较安全的文章加密：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;静态博客加密的核心问题在于&lt;strong&gt;内容已经暴露&lt;/strong&gt;。&lt;/li&gt;
&lt;li&gt;SSR 可以让文章内容只存在于服务端，前端无法直接获取。&lt;/li&gt;
&lt;li&gt;前端只负责用户交互，验证逻辑和内容展示都交由服务端处理。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;对于需要文章加密的博客来说，这种方式在安全性和实现复杂度之间，算是一个比较理想的平衡点。&lt;br&gt;
如果你也想在自己的博客里尝试类似的加密机制，可以参考本文的思路进行改造。&lt;/p&gt;
&lt;p&gt;如果对于文章中提到的改动仍有疑问，可以前往 GitHub 查看本次改造的提交：&lt;a href=&quot;https://github.com/zxc7563598/astro-theme-pures/pull/1&quot;&gt;点我查看&lt;/a&gt;&lt;/p&gt;</content:encoded><h:img src="/_astro/web.n3Pk-HlC.jpg"/><enclosure url="/_astro/web.n3Pk-HlC.jpg"/></item><item><title>Web3 去魅：写给程序员和普通人的技术解读</title><link>https://hejunjie.life/blog/a9d922ut</link><guid isPermaLink="true">https://hejunjie.life/blog/a9d922ut</guid><description>简明解析区块链、钱包、智能合约等核心概念，探讨去中心化的理想与现实差距。揭示 Web3 当前面临的挑战，如慢速交易、高费用和用户体验差，帮助你全面理解 Web3 的局限性与发展潜力</description><pubDate>Wed, 05 Nov 2025 17:27:33 GMT</pubDate><content:encoded>&lt;p&gt;以为 Web3 已经没动静了，结果最近刷小红书的时候，我又碰到了那些所谓的“Web3 大师”。&lt;br&gt;
你懂吧，就是那种典型的人设，大厂导师，一坐上去就开始散发“老子站在时代最前沿”的气场。&lt;/p&gt;
&lt;p&gt;“Java ？卷！Go？ 已经落后了！不要聊前端了前端已经死了！后端被 AI 取代了！”&lt;/p&gt;
&lt;p&gt;然后在平等的侮辱了所有人之后，一副老子给指条活路的态度在那里讲：“Web3 才是未来，我认识的谁谁谁，去转 Web3，现在已经在接海外的项目，一个月赚多少多少钱了，还有我之前介绍转 Web3 的谁谁谁，现在已经跳槽到什么什么地方赚了多少多少……”&lt;br&gt;
完了头像点进去一看，得了，卖课的。&lt;/p&gt;
&lt;p&gt;问题是，这种内容不仅我能刷到，我父母也会刷到。在饭桌上，父母已经开始问：“我们国家那个区块链发展的怎么样啊？是不是比不过美国啊？”、“未来不都说什么 3.0，全都用区块链，高科技啊。”&lt;br&gt;
听得我一顿头大。&lt;/p&gt;
&lt;p&gt;然而，当你真的去搜“Web3”，能找到的，要么是大多是一堆名词：区块链、智能合约、去中心化……落地的东西却少得可怜。要么是跟你讲1.0时代是只读网络，2.0时代是交互式网络，3.0时代就是去中心化，然后去宣扬网络会进入一个可读可写可拥有的时代，资产属于你个人，资本跟国家再也不能迫害你，落到实处的还是一点都没有&lt;/p&gt;
&lt;p&gt;所以，我想写这篇文章，直接分享给父母，给他们简单讲讲 Web3 到底是个什么玩意儿，用人话解释出来，不神话、不吹嘘。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;区块链 - 全世界一起记账的协议&lt;/h2&gt;
&lt;p&gt;在了解钱包和智能合约之前，先讲讲区块链本身。&lt;/p&gt;
&lt;p&gt;区块链其实就是一套&lt;strong&gt;协议&lt;/strong&gt;，就像现在互联网的协议一样，用来连接全球所有遵循这个协议的计算机。&lt;/p&gt;
&lt;p&gt;任何一台电脑都可以加入这个协议，成为其中的一员，并共同保存协议中的所有数据。&lt;/p&gt;
&lt;p&gt;大家一起记账的例子举烂了，我换一个例子来举例：&lt;/p&gt;
&lt;p&gt;区块链就像一个房间，加入房间的每个人都清楚里面发生了什么。&lt;/p&gt;
&lt;p&gt;任何人单独撒谎、篡改信息都无济于事，因为其他人都知道真实情况。&lt;/p&gt;
&lt;p&gt;房间里的每个人越多，数据就越安全；&lt;/p&gt;
&lt;p&gt;只有当房间里一台计算机都没有时，数据才可能消失（所以区块链的安全性依赖于节点的数量和活跃度）&lt;/p&gt;
&lt;p&gt;它有这么几个特点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;所有信息都在同步&lt;/strong&gt;：每台加入协议的计算机都保存一份完整的数据副本；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;数据一致性靠多数共识&lt;/strong&gt;：如果部分节点出现错误或被破坏，其他多数节点的数据会覆盖错误；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;数据几乎不可能被篡改&lt;/strong&gt;：你无法在短时间内同时改动全球大多数节点的数据，因此安全性很高；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;节点关闭不怕&lt;/strong&gt;：只要还有一台计算机在运行节点，数据就不会丢失。&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;当然，每台计算机作为一个节点，他也是分普通节点跟验证节点。&lt;/p&gt;
&lt;p&gt;普通节点还分全节点跟轻节点。&lt;/p&gt;
&lt;p&gt;不是每一个节点都是同步所有数据的全节点，那对网络还有存储要求很高。&lt;/p&gt;
&lt;p&gt;但这不重要，只是简单说明下他概念是这么个概念&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;‍&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;钱包 - 你的数字身份证&lt;/h2&gt;
&lt;p&gt;在 Web3 世界里，你不再用“用户名 + 密码”来登录网站，而是用一个叫 &lt;strong&gt;钱包&lt;/strong&gt; 的东西。&lt;br&gt;
它就像是你在区块链上的&lt;strong&gt;身份证&lt;/strong&gt;，证明“你是谁”。&lt;/p&gt;
&lt;p&gt;之所以叫“钱包”，是因为最早的区块链主要用于转账、存币。&lt;/p&gt;
&lt;p&gt;我的资产从“我的钱包”转到“你的钱包”。这个名字沿用下来，慢慢也代表了你在区块链世界的身份与资产。&lt;/p&gt;
&lt;p&gt;常见的钱包软件有很多，比如 &lt;strong&gt;MetaMask、Rabby Wallet、Phantom、Keplr、Trust Wallet、OKX Wallet、imToken、Bitget Wallet&lt;/strong&gt; 等。&lt;br&gt;
虽然种类繁多，但它们的核心都离不开这几个关键部分：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;地址（Address）&lt;/strong&gt; ：你的公开身份，就像门牌号或身份证号，别人可以通过它找到你。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;私钥（Private Key）&lt;/strong&gt; ：你的“数字DNA”，只有你自己拥有。它能生成你的公钥和地址，用来证明“你就是你”。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;签名（Signature）&lt;/strong&gt; ：当你发起操作时，钱包会用私钥生成签名。别人可以验证这个签名确实来自你，但无法伪造。这就是区块链信任机制的基础。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;‍&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;智能合约 - 链上的程序&lt;/h2&gt;
&lt;p&gt;在传统互联网里，我们写的程序代码通常部署在&lt;strong&gt;公司的服务器&lt;/strong&gt;上，由公司来运行和维护。&lt;br&gt;
而在 Web3 世界里，程序不是放在某个公司的服务器上，而是直接&lt;strong&gt;发布到区块链上&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;这段代码就叫 &lt;strong&gt;智能合约（Smart Contract）&lt;/strong&gt; 。&lt;/p&gt;
&lt;p&gt;与其说它是一段代码，不如说它是一条“&lt;strong&gt;谁都改不了的规则&lt;/strong&gt;”。&lt;/p&gt;
&lt;p&gt;举个例子，比如你写了一个“众筹”的规则：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;如果 总筹款金额 ≥ 目标金额
→ 自动把钱打给发起人
否则
→ 自动退还给所有出资者
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这段逻辑一旦被上传到区块链上，就会同时被所有节点同步、验证和执行。&lt;/p&gt;
&lt;p&gt;从此以后：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;没有人能私自更改规则，因为所有节点都记录了它；&lt;/li&gt;
&lt;li&gt;所有节点都会按照规则自动执行；&lt;/li&gt;
&lt;li&gt;执行结果对所有人公开可查，任何人都能验证。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;没有“人工审批”，也没有“后台暗箱操作”。&lt;br&gt;
它就像一段&lt;strong&gt;永远运行的公开程序&lt;/strong&gt;——输入条件，执行逻辑，输出结果。&lt;/p&gt;
&lt;p&gt;换句话说，智能合约并不依赖某一台服务器，而是分布在全球的节点上共同运行。&lt;br&gt;
正因为如此，它&lt;strong&gt;不可篡改、公开透明、自动执行&lt;/strong&gt;，这也是 Web3 应用得以去中心化的核心基础。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;费用 - 链上操作要付费&lt;/h2&gt;
&lt;p&gt;那么问题就来了：既然 Web3 不再依赖服务器，那是不是也就不需要付服务器的费用了？&lt;/p&gt;
&lt;p&gt;其实并不是这样。&lt;br&gt;
在区块链上做任何操作，都要消耗&lt;strong&gt;计算资源&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;区块链不是慈善机构。&lt;br&gt;
每一次操作（比如转账、部署合约、执行逻辑）都会被所有节点同步和验证。&lt;br&gt;
而这些节点背后是真实的计算机，它们需要电力、带宽和硬件支持。&lt;br&gt;
因此，系统规定：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;谁使用计算资源，谁就需要支付费用。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;这个费用就叫 &lt;strong&gt;Gas Fee（燃料费）&lt;/strong&gt; 。&lt;/p&gt;
&lt;p&gt;操作越复杂，消耗的 Gas 越多。&lt;br&gt;
这也是为什么链上不适合直接存放大文件或频繁操作的原因。&lt;/p&gt;
&lt;p&gt;举个例子，如果我希望一个大文件&lt;strong&gt;不被篡改&lt;/strong&gt;，&lt;br&gt;
我不需要把整个文件上传到区块链上。&lt;br&gt;
我只需要把文件的&lt;strong&gt;哈希值（Hash）&lt;/strong&gt; 存到链上，之后任何人只要重新计算文件的哈希并与链上记录比对，&lt;br&gt;
就能确认文件是否被改动。&lt;/p&gt;
&lt;p&gt;这样既节省 Gas，又能保持数据的&lt;strong&gt;可信性与可验证性&lt;/strong&gt;。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;节点是谁提供的&lt;/h2&gt;
&lt;p&gt;节点其实就是在跑区块链软件的电脑，可以是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;个人开发者在家里的电脑&lt;/li&gt;
&lt;li&gt;矿工或验证者的服务器&lt;/li&gt;
&lt;li&gt;机构公司搭建的节点&lt;/li&gt;
&lt;li&gt;提供节点服务的公司（比如 Infura、Alchemy），让你不用自己跑节点也能和链交互&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;去中心化的核心就是节点分布广泛，不存在某个地方关闭就能毁掉整个链的情况。只要还有节点在，链就能继续运行。&lt;/p&gt;
&lt;p&gt;早期的挖矿就是围绕这个机制展开的：区块链中，每一秒都有各种事情发生：有人转账，有人操作合约，有人执行程序。如果每一秒的所有事情都让每台电脑实时记下来，信息量会太大，根本保存不过来。&lt;/p&gt;
&lt;p&gt;于是区块链有个规则：每隔一段时间，比如两分钟，把过去两分钟里发生的事情整理成一个“时间归档”，然后同步给全世界的节点。大家都保存一份副本，就像把过去的时间切成一段段的小片段，既保证了数据完整，也不会让整个链庞大到无法管理。&lt;/p&gt;
&lt;p&gt;但是，谁来做这份归档呢？全世界的节点都可以参与竞争。系统会发布一道非常难的数学题，节点们用自己的算力去解，谁先解出来，谁就有权利把这一段时间的事情整理好，并广播给所有人。成功的节点就能得到奖励，包括系统发的区块链币和这一段时间所有操作支付的 Gas。&lt;/p&gt;
&lt;p&gt;算力越强的节点，越有机会赢得这场“时间归档赛”。所以早些年，大家都疯狂投入算力去挖矿，本质上就是用自己的能力去竞争记录过去的时间片段，同时获得奖励。&lt;/p&gt;
&lt;p&gt;当然因为这种方式太耗电，把很多人卷进了一场无限战争，因此后来也出现了很多新的机制，比如靠抵押资金或者信誉度来证明自己有资格而不纯粹靠算力。&lt;/p&gt;
&lt;p&gt;但不论如何这个模式吸引了很多人，让其有动力成为区块链的节点，变相保证了整个区块链几乎不可能被摧毁，因为不管全球哪个犄角旮旯只要还有一个节点整个区块链就依然存在&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Web3 的局限和现实&lt;/h2&gt;
&lt;p&gt;虽然 Web3 技术看起来很吸引人，但现实中还是面临不少问题：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;链上操作慢，费用高；&lt;/li&gt;
&lt;li&gt;用户体验差：丢了私钥就意味着钱包没了，交易得等矿工确认；&lt;/li&gt;
&lt;li&gt;完全去中心化仍然是理想，很多 DApp 仍然依赖中心化的前端或者缓存；&lt;/li&gt;
&lt;li&gt;大部分 Web3 应用仍然处于实验阶段，炒作和投机现象严重。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;区块链因为按时间段归档的特性，本身就不具备严格的实时性。每笔操作都得等到下一个区块确认，才能被全网认可。&lt;/p&gt;
&lt;p&gt;区块链追求的核心是&lt;strong&gt;数据的透明性、可靠性和不可篡改性&lt;/strong&gt;，而不是瞬时同步。换句话说，Web3 很难像传统互联网那样，支持毫秒级的实时交互。&lt;/p&gt;
&lt;p&gt;同时，因为每次操作都要支付 Gas 费用，数据上链的成本也挺高，所以下链的东西也不能随便放进去。&lt;/p&gt;
&lt;p&gt;再说说私钥管理的问题，这其实是 Web3 设计里的一个理想化部分。在传统的中心化模式下，用户面对的是一个有责任的主体。就像我们用化妆品，如果出了问题，我们不需要追究每个成分来源，只要找化妆品公司负责；如果不行，往上找政府管。&lt;/p&gt;
&lt;p&gt;但在去中心化的世界里，这个“责任主体”就没了。很多人可能觉得这就意味着自由，但随之而来的却是保障的消失。没人会为你的损失负责。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;比如我把钱放在支付宝，数据是中心化的，支付宝说了算。如果我的手机丢了或者忘了密码，我可以去找支付宝，他们能帮我找回我的钱。&lt;br&gt;
但如果我的钱在区块链上，确实去中心化了，但如果我丢了私钥，那就真的没救了。别人偷不走的钱，我们自己也拿不回来。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;这并不意味着 Web3 毫无意义。它依然有它的市场和价值，只是目前在我们现有的社会体制下，它的发展空间是有限的。十多年来，Web3 的发展仍然没能摆脱“炒币”的刻板印象，吸引了大量投机者，但至今没有出现像抖音、淘宝这样真正的大众应用。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;总结&lt;/h2&gt;
&lt;p&gt;Web3 并不是魔法，也不是币圈炒作的工具。它的核心逻辑是：&lt;/p&gt;
&lt;p&gt;你写的代码和数据，不存在某台服务器，而是分布在全网节点，每个节点都帮你保存和验证。&lt;/p&gt;
&lt;p&gt;去中心化的本质是 &lt;strong&gt;全网节点共同维护数据和程序，不依赖某个公司或者服务器&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;他不是新大陆，而是给互联网装了身份证和数字钱包，把程序和数据交给全网节点一起保存验证。&lt;/p&gt;
&lt;p&gt;理解这个逻辑，比盲目追风口或者听直播吹牛更有价值。&lt;/p&gt;</content:encoded><h:img src="/_astro/web.n3Pk-HlC.jpg"/><enclosure url="/_astro/web.n3Pk-HlC.jpg"/></item><item><title>收藏版：Phinx 数据库迁移完全指南</title><link>https://hejunjie.life/blog/d92j5hsf</link><guid isPermaLink="true">https://hejunjie.life/blog/d92j5hsf</guid><description>一篇能直接上手的 Phinx 数据库迁移完整指南，涵盖表结构创建、字段类型、参数说明与实用技巧。不用翻文档，照着写就能跑</description><pubDate>Tue, 28 Oct 2025 10:30:12 GMT</pubDate><content:encoded>&lt;p&gt;最近在维护老项目时，又一次用到了 &lt;strong&gt;Phinx&lt;/strong&gt;。
这个工具我已经用了很多年，几乎每个项目都会用上它。它属于那种&lt;strong&gt;平时不常用，但每个项目都离不开&lt;/strong&gt; 的工具。&lt;/p&gt;
&lt;p&gt;问题在于，它用得不频繁，每次写迁移脚本时总会忘记某个参数怎么写、某个字段该用什么类型。
这些当然可以去查官方文档，但 Phinx 的文档虽然内容齐全，却总让我觉得&lt;strong&gt;信息分散、查起来不够顺手&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;于是，我干脆花点时间，把自己常用的命令、配置方式、字段类型和参数说明都系统地整理了一遍。
一方面方便自己查阅，另一方面也希望能帮到同样在项目中使用 Phinx 的开发者。&lt;/p&gt;
&lt;p&gt;如果你也在 PHP 项目里用 Phinx 管理数据库迁移，这篇文章或许能成为你的「快捷参考手册」。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;前期配置&lt;/h2&gt;
&lt;h3&gt;安装 Phinx&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;composer require robmorgan/phinx
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;初始化配置&lt;/h3&gt;
&lt;p&gt;执行初始化命令：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-shell&quot;&gt;vendor/bin/phinx init
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;该命令会在项目根目录下生成一个 &lt;code&gt;phinx.php&lt;/code&gt; 配置文件。&lt;br&gt;
配置文件中：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;​&lt;code&gt;paths&lt;/code&gt; 用于指定迁移脚本与填充脚本的路径；&lt;/li&gt;
&lt;li&gt;​&lt;code&gt;environments&lt;/code&gt; 用于定义不同环境（例如开发、测试、生产）的数据库配置；&lt;/li&gt;
&lt;li&gt;若执行迁移命令时不指定环境，则默认使用 &lt;code&gt;default_environment&lt;/code&gt; 指定的环境。&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3&gt;环境配置与 .env 支持&lt;/h3&gt;
&lt;p&gt;为了统一管理数据库配置，我习惯用 &lt;code&gt;.env&lt;/code&gt; 文件来维护连接信息。&lt;br&gt;
但由于 &lt;code&gt;phinx&lt;/code&gt; 脚本并不运行在 PHP 框架生命周期内，常见的 &lt;code&gt;.env&lt;/code&gt; 加载方式（如 Laravel 的）无法直接使用，因此我通常会额外安装 &lt;code&gt;vlucas/phpdotenv&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;composer require vlucas/phpdotenv
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后在 &lt;code&gt;phinx.php&lt;/code&gt; 中添加如下配置：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;&amp;#x3C;?php
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
$dotenv-&gt;load();
return [
    &quot;paths&quot; =&gt; [
        &quot;migrations&quot; =&gt; &quot;database/migrations&quot;,
        &quot;seeds&quot; =&gt; &quot;database/seeds&quot;
    ],
    &quot;environments&quot; =&gt; [
        &quot;default_migration_table&quot; =&gt; &quot;phinxlog&quot;,
        &quot;default_environment&quot; =&gt; &quot;production&quot;,
        &quot;production&quot; =&gt; [
            &quot;adapter&quot; =&gt; &quot;mysql&quot;,
            &quot;host&quot; =&gt; $_SERVER[&apos;DB_HOST&apos;],
            &quot;name&quot; =&gt; $_SERVER[&apos;DB_NAME&apos;],
            &quot;user&quot; =&gt; $_SERVER[&apos;DB_USER&apos;],
            &quot;pass&quot; =&gt; $_SERVER[&apos;DB_PASS&apos;],
            &quot;port&quot; =&gt; $_SERVER[&apos;DB_PORT&apos;],
            &quot;charset&quot; =&gt; $_SERVER[&apos;DB_CHARSET&apos;]
        ]
    ]
];
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;常用命令&lt;/h2&gt;
&lt;h3&gt;创建迁移文件&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-shell&quot;&gt;vendor/bin/phinx create 迁移名称
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Phinx 会自动根据时间戳与名称生成迁移文件。&lt;br&gt;
命名建议保持统一规范：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;创建表&lt;/strong&gt;：&lt;code&gt;Create + 表名&lt;/code&gt; → 例如 &lt;code&gt;CreateUsersTable&lt;/code&gt;​&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;修改表&lt;/strong&gt;：&lt;code&gt;Modify + 表名 + 操作&lt;/code&gt; → 例如 &lt;code&gt;ModifyUsersAddStatus&lt;/code&gt;​&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;删除表&lt;/strong&gt;：&lt;code&gt;Delete + 表名&lt;/code&gt; → 例如 &lt;code&gt;DeleteOldLogsTable&lt;/code&gt;​&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;Phinx 会自动将大驼峰命名转为蛇形文件名，例如 &lt;code&gt;CreateUsersTable&lt;/code&gt; → &lt;code&gt;20251028123045_create_users_table.php&lt;/code&gt;​&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3&gt;执行迁移&lt;/h3&gt;
&lt;p&gt;没什么好说的，执行这个命令即可执行迁移&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-shell&quot;&gt;vendor/bin/phinx migrate
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;参数说明：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;​&lt;code&gt;-e&lt;/code&gt;：指定环境（默认 &lt;code&gt;production&lt;/code&gt;）&lt;/li&gt;
&lt;li&gt;​&lt;code&gt;-t&lt;/code&gt;：指定执行到的版本号（不传则执行到最新）&lt;/li&gt;
&lt;li&gt;​&lt;code&gt;--dry-run&lt;/code&gt;：仅打印 SQL 而不实际执行&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;注意：&lt;code&gt;-t&lt;/code&gt; 不是只执行单个版本，而是顺序执行直到目标版本号为止。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3&gt;设置断点（Breakpoint）&lt;/h3&gt;
&lt;p&gt;生产环境中执行迁移后，建议立刻设置断点。&lt;br&gt;
断点能防止误操作导致大规模回滚。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;vendor/bin/phinx breakpoint
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;参数：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;​&lt;code&gt;-e&lt;/code&gt; 环境名&lt;/li&gt;
&lt;li&gt;​&lt;code&gt;-t&lt;/code&gt; 版本号&lt;/li&gt;
&lt;li&gt;​&lt;code&gt;-r&lt;/code&gt; 删除断点&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3&gt;回滚迁移&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;慎用！&lt;/strong&gt;&lt;br&gt;
迁移的回滚会撤销数据库结构更改，例如删除表或字段，表中数据会全部丢失。&lt;br&gt;
&lt;strong&gt;生产环境不建议直接回滚&lt;/strong&gt;，而是通过新的迁移来完成结构恢复。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;vendor/bin/phinx rollback
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;参数说明：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;​&lt;code&gt;-e&lt;/code&gt; 环境名&lt;/li&gt;
&lt;li&gt;​&lt;code&gt;-t&lt;/code&gt; 指定版本号（或 &lt;code&gt;0&lt;/code&gt; 回滚全部）&lt;/li&gt;
&lt;li&gt;​&lt;code&gt;-d&lt;/code&gt; 按日期回滚（&lt;code&gt;YYYYmmddHHiiss&lt;/code&gt;）&lt;/li&gt;
&lt;li&gt;​&lt;code&gt;-f&lt;/code&gt; 强制回滚（忽略断点）&lt;/li&gt;
&lt;li&gt;​&lt;code&gt;--dry-run&lt;/code&gt; 仅打印 SQL 不执行&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3&gt;查看状态&lt;/h3&gt;
&lt;p&gt;用于查看迁移执行状态，确认哪些迁移尚未执行。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-shell&quot;&gt;vendor/bin/phinx status
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;参数说明：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;-e&lt;/code&gt;：环境名&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;创建填充（Seeder）&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;vendor/bin/phinx seed:create 填充名称
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;命名建议使用表名 + 填充描述，例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;UsersAddDemoDataSeeder
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;执行填充：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;vendor/bin/phinx seed:run
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;参数：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;​&lt;code&gt;-e&lt;/code&gt; 指定环境&lt;/li&gt;
&lt;li&gt;​&lt;code&gt;-s&lt;/code&gt; 指定执行的 Seeder，可多次传入&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;迁移脚本详解&lt;/h2&gt;
&lt;h3&gt;支持的字段类型&lt;/h3&gt;
&lt;p&gt;Phinx 在 MySQL 下支持的字段类型如下：&lt;/p&gt;
&lt;p&gt;| 类型             | MySQL 类型   | 必需参数              | 说明               |
| ---------------- | ------------ | --------------------- | ------------------ |
| ​&lt;code&gt;smallinteger&lt;/code&gt;​ | SMALLINT     | —                     | 小整数             |
| ​&lt;code&gt;integer&lt;/code&gt;​      | INT          | —                     | 常规整数           |
| ​&lt;code&gt;biginteger&lt;/code&gt;​   | BIGINT       | —                     | 大整数             |
| ​&lt;code&gt;float&lt;/code&gt;​        | FLOAT        | —                     | 单精度浮点数       |
| ​&lt;code&gt;double&lt;/code&gt;​       | DOUBLE       | —                     | 双精度浮点数       |
| ​&lt;code&gt;decimal&lt;/code&gt;​      | DECIMAL(M,D) | ​&lt;code&gt;precision&lt;/code&gt;,&lt;code&gt;scale&lt;/code&gt;​ | 定点小数（金额等） |
| ​&lt;code&gt;bit&lt;/code&gt;​          | BIT          | —                     | 位字段             |
| ​&lt;code&gt;boolean&lt;/code&gt;​      | TINYINT(1)   | —                     | 布尔值（0/1）      |
| ​&lt;code&gt;char&lt;/code&gt;​         | CHAR(n)      | ​&lt;code&gt;limit&lt;/code&gt;​             | 定长字符串         |
| ​&lt;code&gt;string&lt;/code&gt;​       | VARCHAR(n)   | ​&lt;code&gt;limit&lt;/code&gt;​             | 可变长度字符串     |
| ​&lt;code&gt;text&lt;/code&gt;​         | TEXT         | —                     | 文本               |
| ​&lt;code&gt;enum&lt;/code&gt;​         | ENUM(...)    | ​&lt;code&gt;values&lt;/code&gt;​            | 枚举类型           |
| ​&lt;code&gt;set&lt;/code&gt;​          | SET(...)     | ​&lt;code&gt;values&lt;/code&gt;​            | 集合类型           |
| ​&lt;code&gt;uuid&lt;/code&gt;​         | CHAR(36)     | —                     | UUID字符串         |
| ​&lt;code&gt;date&lt;/code&gt;​         | DATE         | —                     | 日期               |
| ​&lt;code&gt;time&lt;/code&gt;​         | TIME         | —                     | 时间               |
| ​&lt;code&gt;datetime&lt;/code&gt;​     | DATETIME     | —                     | 日期与时间         |
| ​&lt;code&gt;timestamp&lt;/code&gt;​    | TIMESTAMP    | —                     | 时间戳             |
| ​&lt;code&gt;binary&lt;/code&gt;​       | BINARY(n)    | ​&lt;code&gt;limit&lt;/code&gt;​             | 定长二进制数据     |
| ​&lt;code&gt;blob&lt;/code&gt;​         | BLOB         | —                     | 二进制大对象       |
| ​&lt;code&gt;tinyblob&lt;/code&gt;​     | TINYBLOB     | —                     | 小型二进制         |
| ​&lt;code&gt;mediumblob&lt;/code&gt;​   | MEDIUMBLOB   | —                     | 中型二进制         |
| ​&lt;code&gt;longblob&lt;/code&gt;​     | LONGBLOB     | —                     | 超大二进制         |
| ​&lt;code&gt;json&lt;/code&gt;​         | JSON         | —                     | JSON 数据类型      |&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;常用字段参数&lt;/h3&gt;
&lt;p&gt;| 参数名             | 类型         | 说明                                                            |
| ------------------ | ------------ | --------------------------------------------------------------- |
| ​&lt;code&gt;limit / length&lt;/code&gt;​ | int          | 长度或字节限制                                                  |
| ​&lt;code&gt;default&lt;/code&gt;​        | mixed        | 默认值                                                          |
| ​&lt;code&gt;null&lt;/code&gt;​           | bool         | 是否允许为 NULL                                                 |
| ​&lt;code&gt;after&lt;/code&gt;​          | string       | 放置在某个字段之后（或 \Phinx\Db\Adapter\MysqlAdapter::FIRST ） |
| ​&lt;code&gt;comment&lt;/code&gt;​        | string       | 字段注释                                                        |
| ​&lt;code&gt;precision&lt;/code&gt;​      | int          | 小数总位数                                                      |
| ​&lt;code&gt;scale&lt;/code&gt;​          | int          | 小数位数                                                        |
| ​&lt;code&gt;signed&lt;/code&gt;​         | bool         | 是否允许负数                                                    |
| ​&lt;code&gt;values&lt;/code&gt;​         | array/string | 枚举或集合可选值，英文逗号隔开的字符串或数组                    |
| ​&lt;code&gt;identity&lt;/code&gt;​       | bool         | 是否自增（需搭配 null:false）                                   |&lt;/p&gt;
&lt;h3&gt;各种操作调用代码&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;如果对表格形式不感兴趣，可以直接通过以下完整迁移脚本理解字段定义方式。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;&amp;#x3C;?php

declare(strict_types=1);

use Phinx\Db\Adapter\MysqlAdapter;
use Phinx\Migration\AbstractMigration;

final class CreateTable extends AbstractMigration
{
    /**
     * Change Method.
     *
     * Write your reversible migrations using this method.
     *
     * More information on writing migrations is available here:
     * https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
     *
     * Remember to call &quot;create()&quot; or &quot;update()&quot; and NOT &quot;save()&quot; when working
     * with the Table class.
     */
    public function change(): void
    {
        // 前置说明：
        // create() 操作: 表不存在的情况下，创建表，并保存字段操作
        // update() 操作: 表存在的情况下，变更表，并保存字段操作
        // 所有的操作记得到最后一定需要对构建的表对象执行保存操作，否则不会执行

        // 判断表是否存在: $this-&gt;hasTable(&apos;table_name&apos;);

        // 删除表: $this-&gt;table(&apos;table_name&apos;)-&gt;drop()-&gt;save();

        // 构建表
        $table = $this-&gt;table(&apos;table_name&apos;, [&apos;id&apos; =&gt; &apos;table_id&apos;, &apos;comment&apos; =&gt; &apos;这是 table 表&apos;]);
        // 第二个参数字段，可不传，主要用于在 create() 时构建表级的基本信息
        // 支持的参数：
        // id:            主键 ID 列，传入字符串或者布尔类型
        //                    如果是字符串：按照字符串内容创建自增主键
        //                    如果是false：不自动创建主建
        //                    如果不传：自动创建名称为 id 的自增主键
        // comment:       设置表的注释
        // row_format:    设置表的行格式（MySQL 特有，例如 DYNAMIC, COMPACT, REDUNDANT, COMPRESSED）
        // engine:        设置表的存储引擎（MySQL 特有，例如 InnoDB, MyISAM, MEMORY）
        // collation:     设置字符集的排序规则，默认 utf8mb4_unicode_ci
        // signed:        整数类型是否允许负数，默认 false
        // limit:         设置主键最大长度

        // 变更表注释
        $table-&gt;changeComment(&apos;这是 table 表新的注释&apos;);
        // 更改主键，可以设置多个列组联合主键
        $table-&gt;changePrimaryKey([&apos;new_id&apos;]);
        // 重命名表
        $table-&gt;rename(&apos;new_table_name&apos;);

        // 检查字段是否存在：$table-&gt;hasColumn(&apos;username&apos;);

        // 重命名字段
        $table-&gt;renameColumn(&apos;老字段名&apos;, &apos;新字段名&apos;);

        // 删除字段
        $table-&gt;removeColumn(&apos;字段名&apos;);

        // 添加字段
        $table-&gt;addColumn(&apos;字段名&apos;, &apos;类型&apos;, [&apos;参数&apos;])
              -&gt;addColumn(&apos;avatar&apos;, &apos;string&apos;, [&apos;comment&apos; =&gt; &apos;头像&apos;, &apos;null&apos; =&gt; true, &apos;after&apos; =&gt; &apos;name&apos;])
              -&gt;addColumn(&apos;created_at&apos;, &apos;integer&apos;, [&apos;comment&apos; =&gt; &apos;创建时间&apos;, &apos;null&apos; =&gt; false, &apos;limit&apos; =&gt; MysqlAdapter::INT_BIG]);
        // 类型：
        // 很多不必要或可以简化定义的类型，例如数字方面，可以全部都设置为 `integer` 通过 `limit` 参数设置长度即可自动创建对应类型
        // 以下是支持的类型：
        // smallinteger(SMALLINT):  小整数
        // integer(INT):            常规整数
        // biginteger(BIGINT):      大整数
        // float(FLOAT):            单精度浮点数
        // double(DOUBLE):          双精度浮点数
        // decimal(DECIMAL(M,D)):   定点小数，必须携带 `precision` 与 `scale` 参数
        // bit(BIT):                位字段
        // boolean(TINYINT(1)):     布尔值（0/1）

        // char(CHAR(n)):           定长字符串，必须携带 `limit` 参数
        // string(VARCHAR(n)):      可变长度字符串，必须携带 `limit` 参数
        // text(TEXT):              文本
        // enum(ENUM(...)):         枚举类型，必须携带 `values` 参数
        // set(SET(...)):           集合类型，必须携带 `values` 参数
        // uuid(CHAR(36)):          UUID字符串

        // date(DATE):              日期（YYYY-MM-DD）
        // time(TIME):              时间（HH:MM:SS）
        // datetime(DATETIME):      日期与时间
        // timestamp(TIMESTAMP):    时间戳

        // binary(BINARY(n)):       定长二进制数据，必须携带 `limit` 参数
        // blob(BLOB):              二进制大对象
        // tinyblob(TINYBLOB):      小型二进制
        // mediumblob(MEDIUMBLOB):  中型二进制
        // longblob(LONGBLOB):      超大二进制
        // json(JSON):JSON          数据类型

        // 参数：
        // 以下是支持的参数：
        // limit / length:          长度，int 类型，内容跟随字段类型变化
        // default:                 默认值，类型根据字段类型变化
        // null:                    是否允许为 null，bool 类型，true 或者 false
        // after:                   指定字段应该放在哪个列之后，string 类型传递列名，或者 \Phinx\Db\Adapter\MysqlAdapter::FIRST
        // comment:                 字段注释，string 类型
        // precision:               结合 scale 设置小数精度，int 类型
        // scale:                   结合 precision 设置小数精度，int 类型
        // signed:                  是否允许负数，bool 类型
        // values:                  枚举类型，可以是英文逗号分隔的string，或array
        // identity:                数字类型自增长，bool类型，需要设置 null:false

        // 修改字段
        $table-&gt;changeColumn(&apos;字段名&apos;, &apos;类型&apos;, [&apos;参数&apos;]);

        // 设置索引
        $table-&gt;addIndex([&apos;字段名&apos;]);

        // 删除索引
        $table-&gt;removeIndex([&apos;字段名&apos;]);
        // 或者
        $table-&gt;removeIndexByName(&apos;索引名&apos;);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;填充脚本（Seeder）示例&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;&amp;#x3C;?php

declare(strict_types=1);

use Phinx\Seed\AbstractSeed;

class TableAddDemoDataSeeder extends AbstractSeed
{
    /**
     * Run Method.
     *
     * Write your database seeder using this method.
     *
     * More information on writing seeders is available here:
     * https://book.cakephp.org/phinx/0/en/seeding.html
     */
    public function run(): void
    {
        // 构建数据
        $data = [
            [
                &apos;body&apos;    =&gt; &apos;foo&apos;,
                &apos;created&apos; =&gt; date(&apos;Y-m-d H:i:s&apos;),
            ],
            [
                &apos;body&apos;    =&gt; &apos;bar&apos;,
                &apos;created&apos; =&gt; date(&apos;Y-m-d H:i:s&apos;),
            ]
        ];
        // 构建表
        $table = $this-&gt;table(&apos;table_name&apos;);

        // 添加数据，并保存
        $table-&gt;insert($data)
              -&gt;saveData();

        // 清空表中的所有数据
        $table-&gt;truncate();
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;老项目迁移接入&lt;/h2&gt;
&lt;p&gt;如果你是从已有数据库切换到 Phinx，可使用 &lt;a href=&quot;https://github.com/odan/phinx-migrations-generator&quot;&gt;&lt;code&gt;phinx-migrations-generator&lt;/code&gt;&lt;/a&gt; 自动生成迁移文件。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;composer require odan/phinx-migrations-generator --dev
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;它会直接读取现有的 &lt;code&gt;phinx.php&lt;/code&gt; 配置，无需额外设置。&lt;br&gt;
执行以下命令即可生成当前数据库结构的迁移文件：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;vendor/bin/phinx-migrations generate
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;最后&lt;/h2&gt;
&lt;p&gt;Phinx 是一个非常轻量却强大的迁移工具，适用于几乎所有独立 PHP 项目。&lt;br&gt;
写这篇笔记的初衷很简单——自己每次都要重新翻文档太麻烦了，不如干脆整理一份系统化的版本。&lt;br&gt;
如果你也有相同的困扰，希望这篇文章能让你变得方便一些
觉得有用的话，别忘了收藏这篇文章～
更多内容可以在我的网站 &lt;a href=&quot;https://hejunjie.life&quot;&gt;https://hejunjie.life&lt;/a&gt; 查看&lt;/p&gt;</content:encoded><h:img src="/_astro/mysql.C4E994Tu.png"/><enclosure url="/_astro/mysql.C4E994Tu.png"/></item><item><title>想让默认头像不再千篇一律，就顺手复刻了一下 GitHub 的思路</title><link>https://hejunjie.life/blog/d94u5hht</link><guid isPermaLink="true">https://hejunjie.life/blog/d94u5hht</guid><description>探索如何让默认头像不再千篇一律，我用 Go 复刻了 GitHub 风格的头像生成逻辑，根据输入生成独一无二的方块头像。文章分享了实现原理、效果展示以及未来扩展的思路。</description><pubDate>Mon, 27 Oct 2025 15:48:46 GMT</pubDate><content:encoded>&lt;p&gt;在各种平台上，初始注册的用户通常都会被分配一个默认头像。&lt;br&gt;
但如果你的平台有互动功能，比如评论、留言、排行榜，一堆一模一样的默认头像排在一起就会显得很单调，甚至有些奇怪。&lt;/p&gt;
&lt;p&gt;当然，你也可以让用户自己去换头像，但现实是：大多数人根本懒得去换。&lt;/p&gt;
&lt;p&gt;于是我就想：&lt;strong&gt;能不能让默认头像也“有点个性”呢？&lt;/strong&gt;&lt;br&gt;
然后我想到了 GitHub。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;起因&lt;/h2&gt;
&lt;p&gt;GitHub 的默认头像其实挺有意思的。&lt;br&gt;
每个新用户的头像看起来都不一样，但又有明显的统一风格。&lt;br&gt;
一眼就能认出这是 GitHub 的头像，却几乎不会出现重复。&lt;/p&gt;
&lt;p&gt;出于好奇，我开始想：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;他们到底是怎么做到的？&lt;/li&gt;
&lt;li&gt;是根据用户名生成的吗？&lt;/li&gt;
&lt;li&gt;还是用某种随机算法？&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;于是，我顺手研究了一下 GitHub 默认头像的生成逻辑。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;复刻的过程&lt;/h2&gt;
&lt;p&gt;GitHub 的默认头像基本原理是：&lt;strong&gt;根据用户的唯一标识生成一张方块头像&lt;/strong&gt;，同时随机但可控地分配颜色和图案。&lt;/p&gt;
&lt;p&gt;这种方式有几个显著优势：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;视觉上丰富&lt;/strong&gt;：每个头像都不同，但风格统一&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;生成速度快&lt;/strong&gt;：不需要上传和存储文件&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;用户感知独一无二&lt;/strong&gt;：即使是默认头像，也有个人特色&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;我用 Go 写了一个小项目，实现了类似的功能，并发布在 GitHub 上：&lt;a href=&quot;https://github.com/zxc7563598/avatar-service&quot;&gt;点击查看&lt;/a&gt;。&lt;/p&gt;
&lt;p&gt;它的特点是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;只需传入一个字符串（如用户名），就能生成头像&lt;/li&gt;
&lt;li&gt;颜色、方块分布、比例尽量还原 GitHub 风格&lt;/li&gt;
&lt;li&gt;每个输入都会生成一个独一无二的头像&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;技术小细节&lt;/h2&gt;
&lt;p&gt;简单说一下实现原理：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;首先把输入的字符串（比如用户名）做 &lt;strong&gt;Hash&lt;/strong&gt;，得到一个固定长度的数字序列。&lt;/li&gt;
&lt;li&gt;然后用这个数字序列去生成颜色和方块分布：同样的输入总会得到同样的数字，所以生成的头像也总是一致；不同的输入则会产生不同的数字，自然生成不同的头像。&lt;/li&gt;
&lt;li&gt;具体来说，可以用这些数字去随机生成 &lt;strong&gt;RGB 颜色值&lt;/strong&gt;，同时计算方块的排列方式，从而得到多样化、独一无二的方块头像。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这种方法既保证了风格统一，又能让每个头像看起来不同，效率也很高，不需要存储图片。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;效果展示&lt;/h2&gt;
&lt;p&gt;你可以直接试试这个在线效果：&lt;a href=&quot;https://hejunjie.life/projects/item/avatar-service&quot;&gt;点击查看&lt;/a&gt;。&lt;/p&gt;
&lt;p&gt;输入任意字符串，它就会返回一个专属头像：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;同样的输入，总是生成相同的图案&lt;/li&gt;
&lt;li&gt;不同的输入，总会得到不同的组合&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这就像一个小型的“盲盒”，每次输入都能看到不一样的惊喜。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;未来可能的扩展&lt;/h2&gt;
&lt;p&gt;这个思路其实很有拓展空间。&lt;br&gt;
比如，我想过以后可以像芭比娃娃那样，准备很多简笔风格的人脸元素——头发、眼睛、饰品（比如眼镜）等——然后通过同样的随机方式组合它们，还能随机分配颜色。&lt;/p&gt;
&lt;p&gt;这样一来，每个默认头像不仅是抽象的方块图案，还可以变成&lt;strong&gt;可爱的人物头像&lt;/strong&gt;，依然保持“同样的输入生成同样头像，不同输入生成不同头像”的特性。&lt;br&gt;
未来有时间的话，可能会尝试实现这个版本，给头像多一点“可玩性”。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;小结&lt;/h2&gt;
&lt;p&gt;这个项目虽然小，但做起来意外地有趣。&lt;/p&gt;
&lt;p&gt;它让我意识到，有些东西看起来没什么“实用价值”，却能给用户带来小小的乐趣。&lt;br&gt;
或许它不会改变世界，但至少能让页面多一点变化，也让我在折腾过程中学到不少东西。&lt;/p&gt;
&lt;p&gt;如果你也想让你的平台默认头像不再千篇一律，不妨试试这个方法，也许能给用户带来一点“专属感”。&lt;/p&gt;</content:encoded><h:img src="/_astro/go.aBeHx0xJ.jpg"/><enclosure url="/_astro/go.aBeHx0xJ.jpg"/></item><item><title>在 Astro 博客中优雅使用 51.la 统计数据</title><link>https://hejunjie.life/blog/do295utb</link><guid isPermaLink="true">https://hejunjie.life/blog/do295utb</guid><description>在 Astro 博客中使用 51.la 免费流量统计，通过解析 widget JS 自行渲染访问数据，既保留统计功能，又可自定义展示，让你直观了解博客访客情况</description><pubDate>Fri, 24 Oct 2025 19:44:10 GMT</pubDate><content:encoded>&lt;p&gt;作为老牌网站流量统计服务商，51.la 提供每月高达 &lt;strong&gt;1000 万次的免费统计额度&lt;/strong&gt;，非常适合个人博客或小型网站使用。不过，51.la 默认的统计展示是通过嵌入 JS 文件自动渲染的，这种展示方式对美观性和自定义性有限，对于追求页面整洁或者想要自己设计展示风格的博主来说不太方便。&lt;/p&gt;
&lt;p&gt;我之所以想自己处理 51.la 的统计，是因为我希望&lt;strong&gt;更直观地看到有多少人访问我的博客&lt;/strong&gt;，了解访客的访问情况，从而改进内容和布局。经过尝试，我找到了一个方法：&lt;strong&gt;解析 51.la 的 widget JS 数据，自行在 Astro 中渲染统计数据&lt;/strong&gt;，既保留统计功能，又可以完全控制展示效果。下面分享我的完整经验。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;配置文件管理&lt;/h2&gt;
&lt;p&gt;在 Astro 中，一般会有一个全局配置文件，用来集中管理各种配置。我们可以把 51.la 的相关信息放在一起，示例：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;export const metrics: { enable: boolean; sdk: string; id: string; ck: string; widget: string } = {
  // 51.la 统计开关
  enable: true,
  // 统计网站的配置信息，在后台获取
  // See: https://v6.51.la/report/setup/params/statistics
  sdk: &apos;https://sdk.51.la/js-sdk-pro.min.js&apos;,
  id: &apos;xxxxxxxxxxxxxxxx&apos;,
  ck: &apos;xxxxxxxxxxxxxxxx&apos;,
  // 数据挂件 javascript 地址
  // See: https://v6-widget.51.la/v6/xxxxxxxxxxxxxxxx/quote.js
  widget: &apos;https://v6-widget.51.la/v6/xxxxxxxxxxxxxxxx/quote.js&apos;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果你的项目中没有配置文件，可以在 &lt;code&gt;src&lt;/code&gt; 下创建一个 TS 文件，然后在 &lt;code&gt;tsconfig.json&lt;/code&gt; 的 &lt;code&gt;paths&lt;/code&gt; 中添加，例如：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;&quot;@/site-config&quot;: [&quot;src/site.config.ts&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样，在 Astro 页面中就可以直接引入：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-astro&quot;&gt;---
import { metrics } from &apos;@/site-config&apos;
---
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;集中管理配置的好处是，一旦需要修改统计信息，只需改一处即可，方便维护。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h2&gt;在入口加载统计&lt;/h2&gt;
&lt;p&gt;为了统计用户访问情况，我们需要在页面入口加载 51.la 的 SDK。不同主题的入口文件可能不同，Astro 默认的入口是 &lt;code&gt;src/layouts/Layout.astro&lt;/code&gt;。如果不确定，可以从 &lt;code&gt;pages/index.astro&lt;/code&gt; 开始反推去找包含完整 HTML 结构的布局文件。在布局文件的 &lt;code&gt;&amp;#x3C;head&gt;&lt;/code&gt; 中加入：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-astro&quot;&gt;{metrics.enable &amp;#x26;&amp;#x26; &amp;#x3C;script is:inline src={metrics.sdk} /&gt;}
&amp;#x3C;script is:inline type=&apos;module&apos; data-astro-rerun define:vars={{ metrics: metrics }}&gt;
  if (metrics.enable) {
    LA.init({ id: metrics.id, ck: metrics.ck })
  }
&amp;#x3C;/script&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;注意：对应的文件顶部需要引入配置：&lt;code&gt;import { metrics } from &apos;@/site-config&apos;&lt;/code&gt;​&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;这样，每次用户访问页面时都会触发 51.la 的统计，后台就能记录访客数据。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;创建数据展示组件&lt;/h2&gt;
&lt;p&gt;为了美观，我们可以自己在 &lt;code&gt;components&lt;/code&gt; 里创建一个组件（例如 &lt;code&gt;Metrics.astro&lt;/code&gt;）来展示数据，这样就不依赖 51.la 默认的 JS 渲染。示例：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-astro&quot;&gt;---
import { metrics } from &apos;@/site-config&apos;
---

&amp;#x3C;div id=&apos;widget&apos;&gt;
  加载中...
&amp;#x3C;/div&gt;

&amp;#x3C;script is:inline type=&apos;module&apos; data-astro-rerun define:vars={{ metrics: metrics }}&gt;
  if (metrics.enable &amp;#x26;&amp;#x26; metrics.widget) {
    fetch(metrics.widget)
      .then((res) =&gt; res.text())
      .then((data) =&gt; {
        let num = data.match(/(&amp;#x3C;\/span&gt;&amp;#x3C;span&gt;).*?(\/span&gt;&amp;#x3C;\/p&gt;)/g)
        if (!num) {
          console.warn(&apos;51.la 数据匹配失败&apos;)
          return
        }
        num = num.map((el) =&gt; el.replace(/(&amp;#x3C;\/span&gt;&amp;#x3C;span&gt;|&amp;#x3C;\/span&gt;&amp;#x3C;\/p&gt;)/g, &apos;&apos;))
        const titles = [
          &apos;最近活跃&apos;,
          &apos;今日人数&apos;,
          &apos;今日访问&apos;,
          &apos;昨日人数&apos;,
          &apos;昨日访问&apos;,
          &apos;本月访问&apos;,
          &apos;总访问量&apos;
        ]
        const container = document.getElementById(&apos;widget&apos;)
        let html = &apos;&apos;
        for (let i = 0; i &amp;#x3C; num.length; i++) {
          html += `&amp;#x3C;div&gt;标题：${titles[i]}&amp;#x3C;/div&gt;`
          html += `&amp;#x3C;div&gt;内容：${num[i]}&amp;#x3C;/div&gt;`
        }
        container.innerHTML = html
      })
      .catch((err) =&gt; {
        console.error(&apos;51.la 数据获取失败:&apos;, err)
        document.getElementById(&apos;widget&apos;).innerHTML = &apos;加载失败&apos;
      })
  }
&amp;#x3C;/script&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;样式可以根据自己的需求进行美化，例如网格布局、卡片样式等。这里的重点是把数据准确展示出来，让访客统计信息可视化。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;之后，将你的组件在你想要展示的页面中尽性应用即可，就像其他的组件一样&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;总结&lt;/h2&gt;
&lt;p&gt;通过这种方式，你可以：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;保留 51.la 的统计功能&lt;/strong&gt;，无需自己搭建后端统计系统。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;自定义统计数据显示方式&lt;/strong&gt;，让数据展示更美观、更符合博客风格。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;在 Astro 项目中简单集成&lt;/strong&gt;，配置和组件化管理清晰。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;我之所以选择自己解析 widget 数据，是希望&lt;strong&gt;在博客上直观看到访客情况&lt;/strong&gt;，了解每天有多少人访问、哪些内容更受欢迎。这样可以更有针对性地优化博客内容，同时也享受了 51.la 提供的免费统计额度。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;注意：如果 51.la 的 widget JS 结构有改动，正则匹配可能需要更新。但在目前版本下，这种方法非常稳妥。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;p&gt;这样，你就可以既“白嫖” 51.la 免费流量统计，又能自由控制数据展示效果，同时清楚了解自己的博客访问情况。&lt;/p&gt;</content:encoded><h:img src="/_astro/web.n3Pk-HlC.jpg"/><enclosure url="/_astro/web.n3Pk-HlC.jpg"/></item><item><title>用自己的服务器做一个「临时网络代理」：记录一下开发阶段的一个小技巧</title><link>https://hejunjie.life/blog/di19fk4u</link><guid isPermaLink="true">https://hejunjie.life/blog/di19fk4u</guid><description>记录在开发阶段通过临时代理解决服务器外网依赖下载问题的操作方法。仅涉及客户端配置和命令行使用技巧，不涉及科学上网内容</description><pubDate>Mon, 20 Oct 2025 15:54:39 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;⚠️ 本文不涉及任何“科学上网”内容，仅用于记录我在服务器开发过程中遇到的依赖下载问题。&lt;br&gt;
之前网安的朋友都专门打电话提醒过我，我也不敢乱写。&lt;br&gt;
这里讲的只是一个&lt;strong&gt;开发场景下的代理转发技巧&lt;/strong&gt;，仅涉及客户端问题，不涉及任何服务端内容。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h2&gt;背景：有些烦人的客户&lt;/h2&gt;
&lt;p&gt;很多时候，我们的服务器环境要下载外部依赖：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;PHP 用 Composer 拉包&lt;/li&gt;
&lt;li&gt;Node.js 要 npm&lt;/li&gt;
&lt;li&gt;Python 要 pip&lt;/li&gt;
&lt;li&gt;Go 要 go get&lt;/li&gt;
&lt;li&gt;甚至 Git clone GitHub&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;理论上，我们都可以找国内代理源，比如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Composer 的中国镜像&lt;/li&gt;
&lt;li&gt;npm 的淘宝镜像&lt;/li&gt;
&lt;li&gt;pip 的清华源&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;不过，国内代理源的状况暂且不论——万一挂了，还得去找别的方案。&lt;/p&gt;
&lt;p&gt;问题是，有时候你会遇到这种客户：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;“你做的时候给我录个屏，每一步怎么做的我也学一下”&lt;br&gt;
“你这个东西怎么搞好的，跟我讲一下，下次我自己来”&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;然后等你把他教会了之后，他居然又来一句：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;“原来这么就好了，我看你们技术也没多难，我自己搞搞看也挺行哈”&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;😑😑😑&lt;/p&gt;
&lt;p&gt;这一不是我的项目，我只是帮忙解决基础问题。&lt;br&gt;
二来我也没收费，一顿教完没个感谢就算了，还贬低人。&lt;/p&gt;
&lt;p&gt;平时有人找我问东西，我还是愿意尽力帮忙，毕竟 &lt;strong&gt;解决问题&lt;/strong&gt;  &lt;strong&gt;=&lt;/strong&gt;  &lt;strong&gt;经验积累&lt;/strong&gt;。&lt;br&gt;
我不讨厌经验积累，顺手帮忙也算交个朋友。&lt;/p&gt;
&lt;p&gt;但有时候遇上完全不懂技术、又不尊重别人工作价值的情况……&lt;br&gt;
我就干脆不折腾那些国内代理源了。&lt;/p&gt;
&lt;p&gt;遇到环境问题，我就直接&lt;strong&gt;让服务器连我自己的服务器做代理&lt;/strong&gt;：&lt;br&gt;
我这边配置好、调整完，把问题解决掉，然后&lt;strong&gt;断掉代理&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;大家要是愿意聊天、问问题，我就教；&lt;br&gt;
碰到那种不尊重人的客户，教了也没意义，反而亏了自己的时间。&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;方案概览&lt;/h3&gt;
&lt;p&gt;我自己用来工作的服务器（工作机）网络通畅，于是我在上面开了一个小小的服务端。&lt;/p&gt;
&lt;p&gt;需要处理环境问题的服务器（开发机）作为客户端连接它，从而获得一个「临时代理隧道」。&lt;/p&gt;
&lt;p&gt;整个过程：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;开发机 → 工作机 → 外部依赖源（GitHub、Composer、npm 等）
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;我只需要保持一次新的连通即可。&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;工作机配置&lt;/h3&gt;
&lt;p&gt;安装客户端（以 Ubuntu 为例）：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;apt install shadowsocks-libev -y
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;编辑 &lt;code&gt;/etc/shadowsocks-libev/config.json&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
    &quot;server&quot;: &quot;0.0.0.0&quot;,
    &quot;server_port&quot;: 8399,
    &quot;password&quot;: &quot;临时密码&quot;,
    &quot;timeout&quot;: 300,
    &quot;method&quot;: &quot;chacha20-ietf-poly1305&quot;,
    &quot;mode&quot;: &quot;tcp_and_udp&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;启动服务：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;systemctl enable shadowsocks-libev
systemctl start shadowsocks-libev
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;到这里工作机就完成了，防火墙记得打开设置好的端口号（比如上面配置里面的8399）&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3&gt;让开发机临时使用这个代理&lt;/h3&gt;
&lt;p&gt;开发机跟工作机的前期步骤相同&lt;/p&gt;
&lt;p&gt;安装客户端（以 Ubuntu 为例）：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;apt install shadowsocks-libev -y
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;编辑 &lt;code&gt;/etc/shadowsocks-libev/config.json&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
    &quot;server&quot;: &quot;工作机IP&quot;,
    &quot;server_port&quot;: 8399,
    &quot;local_address&quot;: &quot;127.0.0.1&quot;,
    &quot;local_port&quot;: 1080,
    &quot;password&quot;: &quot;临时密码&quot;,
    &quot;timeout&quot;: 300,
    &quot;method&quot;: &quot;chacha20-ietf-poly1305&quot;,
    &quot;mode&quot;: &quot;tcp_and_udp&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;启动服务：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;systemctl enable shadowsocks-libev-local@config
systemctl start shadowsocks-libev-local@config
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;之后本地 1080 端口的网络请求就会通过开发机来执行，流程如下：开发机 (127.0.0.1:1080) → 工作机 (8399) → 网络&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4&gt;Git&lt;/h4&gt;
&lt;p&gt;Git 有自己的代理配置：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git config --global http.proxy socks5h://127.0.0.1:1080
git config --global https.proxy socks5h://127.0.0.1:1080
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;Shell / 命令行工具&lt;/h4&gt;
&lt;p&gt;如果你想让 &lt;strong&gt;pip、go get、composer&lt;/strong&gt; 等命令行工具都走这个代理，可以给当前开发机 shell 设置环境变量：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;export https_proxy=socks5h://127.0.0.1:1080
export http_proxy=socks5h://127.0.0.1:1080
export all_proxy=socks5h://127.0.0.1:1080
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;命令执行完后，删除临时设置即可恢复正常网络：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;unset all_proxy http_proxy https_proxy
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果再也不打算上这台代理服务器，也可以直接删除客户端配置，或者我这边直接改掉在服务端的密码即可。&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;总结&lt;/h3&gt;
&lt;p&gt;这套方案可以：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;快速帮客户解决依赖下载问题&lt;/li&gt;
&lt;li&gt;不用折腾国内代理源&lt;/li&gt;
&lt;li&gt;保留自己对操作环境的控制，避免被“白嫖”&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;一句话概括就是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;用完即走，帮你搞定，不教你翻车。&lt;/p&gt;
&lt;/blockquote&gt;</content:encoded><h:img src="/_astro/web.n3Pk-HlC.jpg"/><enclosure url="/_astro/web.n3Pk-HlC.jpg"/></item><item><title>从 Hexo 到 Astro：重构我的个人博客</title><link>https://hejunjie.life/blog/jd82u47w</link><guid isPermaLink="true">https://hejunjie.life/blog/jd82u47w</guid><description>从 Hexo 到 Astro，这不只是一次博客框架的迁移，更是一次关于表达方式的更新。我依然相信，写下点什么是有意义的</description><pubDate>Fri, 17 Oct 2025 12:29:33 GMT</pubDate><content:encoded>&lt;p&gt;写博客这件事，老实说，现在可能不太流行了，流量也未必多，但对我来说，有没有博客是两回事。&lt;/p&gt;
&lt;p&gt;过去几年，我一直用 Hexo 搭建和维护我的博客，主题丰富、社区活跃，用得也很开心。&lt;/p&gt;
&lt;hr&gt;
&lt;blockquote&gt;
&lt;p&gt;老博客：&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;img src=&quot;article/jd82u47w/old1.png&quot; alt=&quot;老博客首页&quot;&gt;
&lt;img src=&quot;article/jd82u47w/old2.png&quot; alt=&quot;老博客文章&quot;&gt;
&lt;img src=&quot;article/jd82u47w/old3.png&quot; alt=&quot;老博客关于我&quot;&gt;&lt;/p&gt;
&lt;p&gt;不过，随着我对博客的需求越来越多，我发现 Hexo 在一些定制化操作上有些局限。于是，我决定尝试用 Astro 来重构我的博客。&lt;/p&gt;
&lt;hr&gt;
&lt;blockquote&gt;
&lt;p&gt;新博客：&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;img src=&quot;article/jd82u47w/new1.png&quot; alt=&quot;新博客首页&quot;&gt;
&lt;img src=&quot;article/jd82u47w/new2.png&quot; alt=&quot;新博客文章&quot;&gt;
&lt;img src=&quot;article/jd82u47w/new3.png&quot; alt=&quot;新博客关于我&quot;&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;关于博客这件事&lt;/h2&gt;
&lt;p&gt;我其实并不指望有人会主动来看我的博客。现在这个时代，社交平台的信息流太快，主动搜索和阅读博客的人越来越少，自然流量几乎可以忽略不计。&lt;br&gt;
但我觉得，有没有人看是一回事，&lt;strong&gt;写不写又是另一回事&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;写东西是好的。&lt;br&gt;
无论是记录生活、整理思路，还是在完成一个项目后做个总结强化记忆，这个过程本身就是一种复盘和沉淀。哪怕只是写在记事本里，都有意义。而当我把这些内容放到自己的博客上，它又变成了另一种存在——像是一张我在网络上的个人名片。&lt;/p&gt;
&lt;p&gt;没有这张“名片”，当然也没什么关系；但有这么一个地方，能承载我的想法、积累和小尝试，我会觉得挺开心的。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;为什么想要重构&lt;/h2&gt;
&lt;p&gt;虽然 Hexo 写博客简单、方便，但有时候我想在页面上增加一些小定制功能或展示自己的项目。Hexo 在这方面略显死板，很难灵活调整。&lt;/p&gt;
&lt;p&gt;我希望能实现几个目标：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;可以继续用 Markdown 写博客，不增加复杂度&lt;/li&gt;
&lt;li&gt;页面可以随意增加一些互动组件或者项目展示&lt;/li&gt;
&lt;li&gt;当有小想法时，可以直接用 React/Vue 去实现&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;总之，我希望博客不仅仅是写文章的地方，还能承载一些创意和技术实践。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Astro 的优势&lt;/h2&gt;
&lt;p&gt;选择 Astro 的主要原因是它的灵活性：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;框架自由&lt;/strong&gt;：可以在同一个页面混合使用 React、Vue、Svelte 等组件&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;性能优秀&lt;/strong&gt;：默认静态生成，访问速度快&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;开发自由度高&lt;/strong&gt;：博客可以作为博客，也可以展示项目或其他创意&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Astro 给了我一个既能保持写作效率，又能随意定制页面的空间，这正是我想要的。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;使用体验与感受&lt;/h2&gt;
&lt;p&gt;重构后的博客体验让我很满意：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;平常写博客依旧简单，Markdown 就够了&lt;/li&gt;
&lt;li&gt;想改动或加入小功能时，可以直接用组件实现&lt;/li&gt;
&lt;li&gt;博客不只是博客，也能展示项目、作品或其他想法&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;虽然我明白现在大家可能已经不太关注博客，也可能没人看，但对我来说，有一个可以自由调整和实践的平台，这种感觉非常爽。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;article/jd82u47w/new4.png&quot; alt=&quot;项目展示&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;article/jd82u47w/new5.png&quot; alt=&quot;仓库demo&quot;&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;总结&lt;/h2&gt;
&lt;p&gt;总的来说，从 Hexo 到 Astro 是一次让我非常满意的重构，更像是一次“重塑表达方式”的过程。&lt;/p&gt;
&lt;p&gt;它让我能在继续写字的同时，也能把我的代码、项目和想法融在一起。这种感觉挺好。&lt;/p&gt;
&lt;p&gt;如果你对博客有定制化需求，或者想尝试更多前端技术，我可以说 Astro 是一个值得尝试的选择。&lt;/p&gt;
&lt;p&gt;不过如果你真的对代码没兴趣，也不想去定制什么东西，只是用来写文章放文章的话，那可能 Hexo 还是会方便一些&lt;/p&gt;
&lt;p&gt;如果你也想了解 Astro 的使用体验，可以来看看 :)&lt;/p&gt;
&lt;p&gt;地址：&lt;a href=&quot;https://hejunjie.life&quot;&gt;hejunjie.life&lt;/a&gt;&lt;/p&gt;</content:encoded><h:img src="/_astro/web.n3Pk-HlC.jpg"/><enclosure url="/_astro/web.n3Pk-HlC.jpg"/></item><item><title>一个小项目的记录：PHP 分账组件</title><link>https://hejunjie.life/blog/62e95bf2</link><guid isPermaLink="true">https://hejunjie.life/blog/62e95bf2</guid><description>一个 PHP 分账组件项目，包含多种内置策略并支持自定义扩展。通过分享开发动机、架构设计和使用示例，为开发者提供实用参考和思路启发</description><pubDate>Mon, 13 Oct 2025 11:32:38 GMT</pubDate><content:encoded>&lt;p&gt;最近整理了一个自己做的小项目——&lt;a href=&quot;https://github.com/zxc7563598/php-trade-splitter&quot;&gt;PHP Trade Splitter&lt;/a&gt; ，是一个交易/利润分账组件。今天想分享一下，也算是记录自己的小成果，也顺便展示一下技术思路。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;为什么会做这个包&lt;/h2&gt;
&lt;p&gt;说白了，就是因为工作/项目里老是碰到分账逻辑：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;平台抽成&lt;/li&gt;
&lt;li&gt;作者收益&lt;/li&gt;
&lt;li&gt;代理或渠道分润&lt;/li&gt;
&lt;li&gt;阶梯奖励&lt;/li&gt;
&lt;li&gt;多级递归计算&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;以前都是直接写死在业务里，每次改需求都得重构，越改越心累。&lt;/p&gt;
&lt;p&gt;于是我想：干脆抽象出来，做一个通用组件，能够：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;快速调用&lt;/strong&gt;，一行代码搞定分账&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;可扩展&lt;/strong&gt;，能注册自定义策略&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;覆盖大多数常见分账规则&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;顺便用它自己解决问题，也方便别人参考。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;架构思路：策略模式解耦&lt;/h2&gt;
&lt;p&gt;核心思想其实挺简单：&lt;strong&gt;策略模式&lt;/strong&gt;。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;​&lt;code&gt;Splitter&lt;/code&gt;：统一入口，负责分账调度&lt;/li&gt;
&lt;li&gt;​&lt;code&gt;StrategyInterface&lt;/code&gt;：所有分账规则都实现它&lt;/li&gt;
&lt;li&gt;​&lt;code&gt;SplitContext&lt;/code&gt;：封装分账上下文（总额、参与者）&lt;/li&gt;
&lt;li&gt;​&lt;code&gt;Allocation&lt;/code&gt;：分账结果对象，支持 &lt;code&gt;toArray()&lt;/code&gt; 输出&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;好处就是，如果以后出现新规则，直接写个策略类注册进去就行，不用动核心逻辑。&lt;/p&gt;
&lt;p&gt;概念上可以想象成这样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;├─ Splitter.php                # 统一入口，负责分账调度
├─ Contracts/
│  └── StrategyInterface.php   # 所有分账规则都实现它
├─ Models/
│  ├── Allocation.php          # 分账结果对象，支持 `toArray()` 输出
│  └── SplitContext.php        # 封装分账上下文（总额、参与者）
└─ Strategies/                 # 内置策略类（Percentage, Fixed, Ladder, Recursive）
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;内置策略概览&lt;/h2&gt;
&lt;p&gt;组件内置了四种策略，覆盖绝大多数场景：&lt;/p&gt;
&lt;h3&gt;百分比分账（Percentage）&lt;/h3&gt;
&lt;p&gt;按比例分配金额。例如 10% 给平台，90% 给作者。
适合固定比例抽成场景。&lt;/p&gt;
&lt;h3&gt;固定金额分账（Fixed）&lt;/h3&gt;
&lt;p&gt;直接指定每个人的金额。总和不能超过总额。
适合代理固定提成或奖励金额场景。&lt;/p&gt;
&lt;h3&gt;阶梯分账（Ladder）&lt;/h3&gt;
&lt;p&gt;根据金额区间设置不同分成比例，适合阶梯奖励或多级代理。
比如收入低于 1k 用 5%，收入 1k~5k 用 10%，超过 5k 用 15%。&lt;/p&gt;
&lt;h3&gt;递归分账（Recursive）&lt;/h3&gt;
&lt;p&gt;适合多级渠道分润，每层收益基于上一层金额计算，然后得到净收益。
避免嵌套循环写复杂逻辑，非常清晰。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;如果你想看具体代码示例，我在仓库里提供了 demo：&lt;a href=&quot;https://github.com/zxc7563598/php-trade-splitter/blob/main/tests/demo.php&quot;&gt;tests/demo.php&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h2&gt;自定义策略&lt;/h2&gt;
&lt;p&gt;除了内置策略，你也可以写自己的策略类，只要实现接口就可以注册。
比如你想写一个“全部给某个人”的策略：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;概念上就是实现一个接口，然后返回总额给指定对象，调用方式和内置策略一致。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h2&gt;使用体验&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;一行代码即可完成分账&lt;/li&gt;
&lt;li&gt;分账结果可以直接转换为数组或 JSON，方便存储或返回&lt;/li&gt;
&lt;li&gt;异常情况和边界条件都有处理，比如比例不合法、总额超出等&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;分享感想&lt;/h2&gt;
&lt;p&gt;其实这个包本身逻辑不复杂，但价值在于&lt;strong&gt;解决重复痛点&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;遇到分账问题不用每次重写逻辑&lt;/li&gt;
&lt;li&gt;可维护性提升&lt;/li&gt;
&lt;li&gt;自己和别人都能快速上手&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以即便是小组件，也可以体现技术思路和设计能力。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;总结&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;这是一个 &lt;strong&gt;轻量、灵活、可扩展的 PHP 分账组件&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;核心就是 &lt;strong&gt;策略模式 + 可注册自定义策略&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;分享出来，也是记录自己技术历程，同时方便别人参考&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果你也碰到分账痛点，或者想看看策略模式在小项目里的应用，可以去看看&lt;/p&gt;</content:encoded><h:img src="/_astro/php.DoApYH8-.png"/><enclosure url="/_astro/php.DoApYH8-.png"/></item><item><title>再也不用翻一堆日志！一键部署轻量级错误监控系统，帮你统一管理 PHP 报错</title><link>https://hejunjie.life/blog/23cf1f6b</link><guid isPermaLink="true">https://hejunjie.life/blog/23cf1f6b</guid><description>为了不再 SSH 上去翻日志，我写了个 Go 小脚本，用来接收远程日志。PHP 负责记录日志，Go 负责存储和展示，按天存储、可远程管理，终于能第一时间知道项目炸了</description><pubDate>Fri, 10 Oct 2025 00:16:07 GMT</pubDate><content:encoded>&lt;p&gt;维护多个项目的人，大概都明白那种感觉。&lt;br&gt;
平时一切都很平静，直到某天，甲方的一句“系统是不是出问题了？”&lt;br&gt;
这时候才发现，问题早就埋在那里了。&lt;/p&gt;
&lt;p&gt;你登录服务器，开始翻日志、看 trace，一边调试一边回想昨天是不是又改了什么。问题最终解决了，但那种被动的感觉始终在心里。&lt;/p&gt;
&lt;p&gt;我后来想：&lt;br&gt;
&lt;strong&gt;这种被动，其实是可以被解决的。&lt;/strong&gt;&lt;br&gt;
有没有可能在客户找上门之前，我就已经知道问题在哪，甚至提前修掉？&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;为什么我需要它&lt;/h2&gt;
&lt;p&gt;我手里有不少 PHP 项目，分散在不同的服务器上。&lt;br&gt;
每个项目都有自己的错误日志，但它们互相独立，没人能统一看到全局情况。&lt;/p&gt;
&lt;p&gt;想主动检查，就得一台一台地上服务器翻日志。&lt;br&gt;
没出事的时候翻这些日志浪费时间，但不看又怕真的出了问题。&lt;/p&gt;
&lt;p&gt;结果就是那种尴尬的状态：&lt;br&gt;
&lt;strong&gt;平时不想看，有事的时候措手不及。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;我不想再这样。&lt;br&gt;
我希望有一个地方，能让我一眼看到所有项目的错误，集中展示、集中分析。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;我想要的样子&lt;/h2&gt;
&lt;p&gt;我设想的工具应该能做到几件事：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;接收所有项目上报的错误信息；&lt;/li&gt;
&lt;li&gt;按日期分类保存，支持分页浏览；&lt;/li&gt;
&lt;li&gt;可以在浏览器中直接看到最近的错误；&lt;/li&gt;
&lt;li&gt;最好还能借助 AI 自动分析错误，帮我快速锁定方向。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这就是 &lt;strong&gt;oh-shit-logger&lt;/strong&gt; 的雏形。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;article/23cf1f6b/0001.png&quot; alt=&quot;列表页&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;article/23cf1f6b/0002.png&quot; alt=&quot;详情页&quot;&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;为什么用 Go，而不是 PHP&lt;/h2&gt;
&lt;p&gt;很多人看到这个项目时会问：&lt;br&gt;
“你不是做 PHP 的？为什么用 Go 写？”&lt;/p&gt;
&lt;p&gt;其实原因很简单：我想让它“随拿随用”。&lt;/p&gt;
&lt;p&gt;PHP 虽然写起来快，但部署环境太麻烦。
不管再怎么简化，总归还是要安装 PHP 环境
而 Go 编译出来是一个&lt;strong&gt;独立的可执行文件&lt;/strong&gt;，&lt;br&gt;
上传到服务器就能跑，不用依赖环境，也不用额外配置。&lt;/p&gt;
&lt;p&gt;这对我来说非常重要。&lt;br&gt;
我只想有个小程序，能轻轻松松丢到任何服务器上运行，&lt;br&gt;
收集日志、展示信息，不占资源，也不出幺蛾子。&lt;/p&gt;
&lt;p&gt;所以最终我选择了 Go。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;它现在能做什么&lt;/h2&gt;
&lt;p&gt;oh-shit-logger 可以在几秒钟内启动。&lt;/p&gt;
&lt;p&gt;在 &lt;a href=&quot;https://github.com/zxc7563598/oh-shit-logger/releases&quot;&gt;Releases&lt;/a&gt; 里下载编译好的版本，上传到服务器后执行：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;chmod +x ./app
./app -port=9999 -retain=7 -user=admin -pass=123123
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后访问&lt;br&gt;
​&lt;code&gt;http://你的服务器IP:端口号/read&lt;/code&gt;&lt;br&gt;
就能看到所有 PHP 项目的错误日志。&lt;/p&gt;
&lt;p&gt;PHP 项目上报也很简单，只需要在异常处理中添加一段上报逻辑：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;curl_setopt($ch, CURLOPT_URL, &apos;http://你的服务器IP:端口号/write&apos;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;错误信息会被自动收集、格式化、展示，并且可以通过接入的 DeepSeek AI 自动生成分析结果，告诉你错误可能的原因和修复方向。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;AI 的部分&lt;/h2&gt;
&lt;p&gt;我接入 DeepSeek，不是为了给项目贴个“AI标签”。&lt;br&gt;
关于 AI，网上的讨论很多。
有人相信它能解决一切，甚至不用写一行代码就能做出完整项目；
也有人觉得 AI 看似帮忙，实际上是在暗地里埋雷；
还有人担心长期依赖它，会让个人的思考和判断慢慢退化。&lt;/p&gt;
&lt;p&gt;我这种小卡拉米肯定是参与不了这种宏大的争论。
对我来说，AI 的上下文长度就摆在那里，它要是承担太多工作，难免会丢掉一些细节，而且随着任务量的增加记忆只会越丢越多
所以我更愿意把它当成一个“辅助”，就像现在的智能驾驶。
它可以给我建议，帮我节省时间，但不要直接动我的东西，最终的决定权还是在我。&lt;/p&gt;
&lt;p&gt;有时候我们看到错误信息，稍微一想就知道大概出了什么问题；
但也总有那么些时刻，比如正在开会、接电话、或者状态不太好时，
AI 能帮我快速过一遍问题，给出大致的方向。
这样我可以少花点心力，留点精力去解决真正麻烦的部分。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;最后的想法&lt;/h2&gt;
&lt;p&gt;我做这个项目，其实是为了解决自己的一种焦虑。&lt;br&gt;
那种“系统运行着，但我不知道它哪天会出事”的焦虑。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;oh-shit-logger&lt;/strong&gt; 让我重新掌握了节奏。&lt;br&gt;
现在我不再等客户来告诉我哪里坏了，&lt;br&gt;
我能提前看到问题、分析它、修掉它。&lt;/p&gt;
&lt;p&gt;这就是它存在的意义。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;项目地址&lt;/h2&gt;
&lt;p&gt;GitHub: &lt;a href=&quot;https://github.com/zxc7563598/oh-shit-logger&quot;&gt;https://github.com/zxc7563598/oh-shit-logger&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;如果这个项目能帮到你，欢迎点个 Star 或提 Issue。&lt;br&gt;
当然他最好帮不到你，希望你部署之后每天面对的都是一个无数据的空列表 🙏&lt;/p&gt;</content:encoded><h:img src="/_astro/go.aBeHx0xJ.jpg"/><enclosure url="/_astro/go.aBeHx0xJ.jpg"/></item><item><title>作为 PHP 开发者，我第一次用 Go 写了个桌面应用</title><link>https://hejunjie.life/blog/d7285de6</link><guid isPermaLink="true">https://hejunjie.life/blog/d7285de6</guid><description>一名 PHP 开发者尝试用 Go 写了一个财务管理桌面应用，从 Web 应用转向本地应用，探索 Go 的跨平台打包与开发体验</description><pubDate>Fri, 26 Sep 2025 07:04:41 GMT</pubDate><content:encoded>&lt;p&gt;我平时是做 PHP 的，工作里基本上都是在写 Web 应用。说实话，写久了难免有点惯性思维：服务器、框架、数据库、API、浏览器。&lt;br&gt;
而这次，我做了点不一样的东西 —— 一个用 Go 写的&lt;strong&gt;财务管理桌面应用&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;很多人可能会觉得奇怪：财务管理、记账软件，这不已经烂大街了吗？随便一搜一大堆，为什么还要自己做一个？&lt;/p&gt;
&lt;p&gt;我其实一开始也没打算做什么大而全的产品，而是因为一个很小的念头：&lt;strong&gt;我想试试 Go 写应用，并打包成 Windows 或 Mac 上能跑的桌面软件&lt;/strong&gt;。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;为什么会有这个念头&lt;/h2&gt;
&lt;p&gt;PHP 的世界里，几乎绕不开服务器。写一个应用，哪怕功能再小，也得起一个 Web 服务，访问地址可能是 &lt;code&gt;http://localhost:8080&lt;/code&gt;，然后浏览器打开用。&lt;br&gt;
这没什么不好，但问题是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;它总归还是个 Web 应用&lt;/strong&gt;，本地其实只是跑个“假服务器”；&lt;/li&gt;
&lt;li&gt;想给别人用，要么部署服务器，要么教别人配环境，挺麻烦；&lt;/li&gt;
&lt;li&gt;没法一键生成一个“能直接安装”的应用。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;而 Go 给了我一种完全不同的体验：随手 &lt;code&gt;go build&lt;/code&gt;，就能编译出一个二进制文件，甚至还能轻松跨平台打包。&lt;br&gt;
这让我很感兴趣，于是就有了这个想法：不如用 Go 写个小工具，然后打包成桌面应用跑跑看。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;为什么是财务管理？&lt;/h2&gt;
&lt;p&gt;那为什么选财务管理呢？因为这个需求对我来说&lt;strong&gt;刚好存在&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;我平时的开销基本都在支付宝和微信里，虽然它们各自有统计，但没法合并在一起。&lt;br&gt;
我更希望把两个平台的账单统一起来，还能对消费分类做个合并统计，好让我清楚钱都花到哪里去了。&lt;/p&gt;
&lt;p&gt;而财务管理这个主题也很适合练手：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;前端：做一个管理后台页面，用来录入、展示、统计；&lt;/li&gt;
&lt;li&gt;后端：提供账单存储和查询的 API；&lt;/li&gt;
&lt;li&gt;再加上：&lt;strong&gt;尝试把它打包成桌面应用&lt;/strong&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;于是我就开工了。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;开发过程中的一些体会&lt;/h2&gt;
&lt;p&gt;做到现在，项目已经算是一个&lt;strong&gt;最基础可用的版本&lt;/strong&gt;了。虽然功能不复杂，但过程里的体会挺有意思。&lt;/p&gt;
&lt;h3&gt;1. Go 确实很“轻”&lt;/h3&gt;
&lt;p&gt;写 PHP 的时候，要部署、要跑服务，框架本身也会预先定义很多结构。&lt;br&gt;
而 Go 就很朴素：从监听端口开始，一切都要自己实现。听起来像是会很麻烦，但实际体验完全不是这样。&lt;/p&gt;
&lt;p&gt;端口监听、配置文件、路由、中间件……几行代码就能完成。&lt;br&gt;
不仅没有在基建上“浪费时间”的感觉，反而有一种&lt;strong&gt;更自由的掌控感&lt;/strong&gt;。这对我来说很新鲜。&lt;/p&gt;
&lt;h3&gt;2. 桌面应用和 Web 应用的边界感&lt;/h3&gt;
&lt;p&gt;虽然这是“桌面应用”，但本质上我还是走前后端分离。&lt;br&gt;
前端在 &lt;code&gt;fintrack-frontend&lt;/code&gt;，后端在 &lt;code&gt;fintrack-backend&lt;/code&gt;，桌面应用只是“换了个壳子”。&lt;br&gt;
可心理上的感觉不一样：它不再是浏览器地址栏里的东西，而是一个真正“装在电脑上的软件”。&lt;/p&gt;
&lt;h3&gt;3. 跨语言的思维切换&lt;/h3&gt;
&lt;p&gt;PHP 写久了，写 Go 的时候会下意识去找框架、找 ORM，结果发现 Go 的生态完全不一样。&lt;br&gt;
刚开始有点别扭，但后来发现语言层面的简洁反而让我能更专注在业务逻辑上，不会被一堆“默认约定”牵着走。&lt;/p&gt;
&lt;h3&gt;4. 做点“无用”的项目很有意思&lt;/h3&gt;
&lt;p&gt;说实话，这个财务管理应用对别人来说可能没什么价值，毕竟现成的工具太多了。&lt;br&gt;
但对我来说，它让我尝到了用 Go 做“非 Web 应用”的可能性，也多了一个贴合我需求的小工具。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;展望&lt;/h2&gt;
&lt;p&gt;目前项目已经开源，可以直接下载到安装包：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;项目仓库：&lt;a href=&quot;https://github.com/zxc7563598/fintrack-backend&quot;&gt;fintrack-backend&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;支持打包 Windows 和 Mac 版本，算是实现了我最初的目标。&lt;/p&gt;
&lt;p&gt;接下来，我可能会继续加一些功能：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;更好看的图表分析；&lt;/li&gt;
&lt;li&gt;多账本支持；&lt;/li&gt;
&lt;li&gt;导入/导出 CSV；&lt;/li&gt;
&lt;li&gt;甚至试试做移动端。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;不过，就算后续不再加功能，这个项目也已经达到了我的目的：&lt;br&gt;
&lt;strong&gt;它让我走出了 PHP + Web 的惯性思维，尝试了另一种可能。&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;很多时候，我们写的小项目，在别人看来可能就是“又一个记账软件”，没什么价值。&lt;br&gt;
但对自己来说，它代表的是一个&lt;strong&gt;学习过程&lt;/strong&gt;，一个&lt;strong&gt;探索新方向的契机&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;所以，如果你也有类似的想法，不妨直接去做。&lt;br&gt;
也许别人不一定用，但你自己的收获，已经足够大了。&lt;/p&gt;</content:encoded><h:img src="/_astro/go.aBeHx0xJ.jpg"/><enclosure url="/_astro/go.aBeHx0xJ.jpg"/></item><item><title>用 PHP 玩向量数据库：一个从小说网站开始的小尝试</title><link>https://hejunjie.life/blog/9dff3f92</link><guid isPermaLink="true">https://hejunjie.life/blog/9dff3f92</guid><description>用 PHP 对接 Milvus 向量数据库的小项目，支持集合、分区、索引、别名和数据操作，实现语义检索，适合在文本搜索场景中使用</description><pubDate>Sat, 06 Sep 2025 01:02:55 GMT</pubDate><content:encoded>&lt;p&gt;有时候折腾东西的起因特别随意。比如我这次，就是从一个“带点颜色”的小说网站开始的。网站里全是几万字的短文，看着还不错，但限制也不少：每天只能看几篇，而且没有任何搜索功能。&lt;/p&gt;
&lt;p&gt;于是我写了个爬虫，把所有小说都抓了下来。数据一下子堆到本地，看似自由了，但新的麻烦随之而来——几万篇文章放在那里，如果想找某个题材、某种情节，几乎不可能。用关键词搜索顶多能凑合，但语义差一点就找不到，体验并不好。&lt;/p&gt;
&lt;p&gt;这时我想到了向量数据库。&lt;/p&gt;
&lt;h2&gt;为什么用 Milvus&lt;/h2&gt;
&lt;p&gt;我的服务器规格很小，只有 2 核 2G，根本跑不动本地的 Milvus，更别说大规模向量化了。好在 Milvus 有托管服务，而且带免费额度。这样我就能换个思路：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Mac 本地&lt;/strong&gt;：用开源模型先做文本向量化，把长篇小说切分成小段（比如 200 个汉字一块），再计算对应的向量。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Milvus 云端&lt;/strong&gt;：专门存储和管理这些向量，负责后续的相似度检索。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;查询时&lt;/strong&gt;：把搜索词同样向量化，发到 Milvus 去比对，得到语义上更接近的内容。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;另外，向量化这一步我用的是 SiliconFlow 提供的模型，免费额度足够支撑实验，算是“白嫖”资源也能玩得转。&lt;/p&gt;
&lt;h2&gt;为什么要写 PHP 库&lt;/h2&gt;
&lt;p&gt;可能有人会问：Milvus 不是有 Python、Go SDK 吗？为什么非要用 PHP？&lt;/p&gt;
&lt;p&gt;原因其实很简单：我的整个小说爬虫和阅读项目，本身就是用 PHP 写的。再硬插一门语言进去，工程会很乱。于是干脆自己写了一个 PHP 库，把常见的操作封装起来：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;创建集合（类似于建表）&lt;/li&gt;
&lt;li&gt;插入数据（上传向量和原文片段）&lt;/li&gt;
&lt;li&gt;向量检索（传入一个向量，返回相似度最高的结果）&lt;/li&gt;
&lt;li&gt;删除集合、清理数据&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这样一来，整个流程都能在 PHP 项目里顺利跑通。&lt;/p&gt;
&lt;h2&gt;怎么用&lt;/h2&gt;
&lt;p&gt;举个简单的例子。假设我有一段文本 &lt;code&gt;&quot;这是一个测试文本&quot;&lt;/code&gt;，先在本地算好向量（这里假设是 &lt;code&gt;[0.12, 0.85, ...]&lt;/code&gt;），然后通过库上传：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;use Hejunjie\Milvus;

$baseUrl = &quot;http://your-milvus-host&quot;
$apiKey = &quot;your-apikey&quot;

$client = new Milvus\Client($baseUrl, $apiKey);

// 插入数据
$client-&gt;entities()-&gt;insert(&quot;novels&quot;, [
    &apos;article_id&apos; =&gt; 14271
    &apos;title&apos; =&gt; &apos;测试小说&apos;,
    &apos;content&apos; =&gt; &apos;这是一个测试文本&apos;,
    &apos;vector&apos; =&gt; [0.12, 0.85, ...]
]);

// 搜索
$results = $client-&gt;entities()-&gt;search(&quot;novels&quot;, [
    [0.11, 0.80, ...], // 搜索内容，通过 SiliconFlow 转换为向量
    [0.11, 0.80, ...], // 支持多个数据查询
    [0.11, 0.80, ...]
], 10);
print_r($results);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样就能查到与输入向量最接近的 10 段文本。通过查询到的 id 就可以在本地数据库中查询对应的小说。
对我来说，只要输入大概的剧情方向，Milvus就能把拥有相似片段的小说找出来，比传统关键字搜索好用太多。&lt;/p&gt;
&lt;h2&gt;支持的方法&lt;/h2&gt;
&lt;p&gt;这个小库目前支持 Milvus 的常见操作，大致分成几类：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;集合（Collections）&lt;/strong&gt; ：创建、删除、重命名、加载/释放、获取状态、设置属性等。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;分区（Partitions）&lt;/strong&gt; ：创建、删除、加载/释放、查看列表、检查是否存在。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;索引（Indexes）&lt;/strong&gt; ：创建、删除、查看详情，或者列出所有索引。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;别名（Aliases）&lt;/strong&gt; ：创建、修改、删除别名，也可以查看现有别名。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;向量数据（Entities）&lt;/strong&gt; ：插入、更新（upsert）、删除、查询、向量搜索。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;其它&lt;/strong&gt;：比如自定义请求、获取集合的统计信息等等。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;简单来说，该有的基本操作都覆盖了，足够支撑一个小型项目的使用。&lt;/p&gt;
&lt;h2&gt;我的收获&lt;/h2&gt;
&lt;p&gt;这个库严格来说不算“生产级”，但它让我有一些新的体会：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;向量数据库没那么遥远&lt;/strong&gt;。不一定要做推荐系统或大模型项目，哪怕只是个人的小需求，也能用上。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PHP 也能玩&lt;/strong&gt;。虽然传统上大家都觉得 PHP 跟 AI 没关系，但只要敢接 API，就能接上去。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;动手才有意思&lt;/strong&gt;。一开始只是因为“不爽”写了个爬虫，后来一步步衍生到向量搜索，最后顺手写了个小库。整个过程不算严肃，但很有乐趣。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以说，整个过程严格意义上没什么“高大上”的地方，就是一个普通人遇到小问题，忍不住折腾了一下，最后顺手造了个小轮子。&lt;/p&gt;
&lt;p&gt;现在呢，我终于可以随心所欲地在几万篇小说里搜索想要的片段了，再也不用翻到眼花还找不到 😂&lt;/p&gt;
&lt;p&gt;项目地址 👉 &lt;a href=&quot;https://github.com/zxc7563598/php-milvus&quot;&gt;zxc7563598/php-milvusr&lt;/a&gt;&lt;/p&gt;</content:encoded><h:img src="/_astro/php.DoApYH8-.png"/><enclosure url="/_astro/php.DoApYH8-.png"/></item><item><title>RSA+AES 混合加密不复杂，但落地挺烦，我用 Vue+PHP 封装成了两个库</title><link>https://hejunjie.life/blog/9634b05b</link><guid isPermaLink="true">https://hejunjie.life/blog/9634b05b</guid><description>RSA+AES 混合加密大家都懂，真正用在项目里却有点繁琐。于是我写了 Vue 前端 npm 包和 PHP 后端库，帮忙处理加解密、签名、时间戳校验，开箱即用</description><pubDate>Thu, 28 Aug 2025 11:23:39 GMT</pubDate><content:encoded>&lt;p&gt;在项目里写接口的时候，我有时候会希望&lt;strong&gt;再多一层保护&lt;/strong&gt;。&lt;br&gt;
虽然 HTTPS 已经能保证传输安全，但它解决的更多是「传输过程中不被窃听/篡改」的问题。&lt;br&gt;
而我还想顺带做到几点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;防止接口被随便模拟调用&lt;/li&gt;
&lt;li&gt;就算数据包被截获，也看不懂内容&lt;/li&gt;
&lt;li&gt;就算有人拿着同一份请求去重放，服务端也能拒绝&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这些需求其实挺常见的，但并不复杂，说白了就是一套 &lt;strong&gt;RSA+AES 混合加密&lt;/strong&gt;。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;经典的思路&lt;/h2&gt;
&lt;p&gt;原理本身没什么新鲜的：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;每次请求生成一个随机 AES Key&lt;/li&gt;
&lt;li&gt;用 AES 加密数据&lt;/li&gt;
&lt;li&gt;再用 RSA 公钥把 AES Key 加密后传给后端&lt;/li&gt;
&lt;li&gt;后端用 RSA 私钥解密出 AES Key，再还原请求体&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这是标准做法，网上能搜到很多讲解。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;真正麻烦的地方&lt;/h2&gt;
&lt;p&gt;难点其实不在原理，而是在项目里真正用的时候。&lt;/p&gt;
&lt;p&gt;比如要自己实现，就得写：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;每次生成随机 AES Key&lt;/li&gt;
&lt;li&gt;RSA 公钥加密 AES Key&lt;/li&gt;
&lt;li&gt;AES 加密/解密请求体&lt;/li&gt;
&lt;li&gt;签名计算、时间戳校验，避免重放&lt;/li&gt;
&lt;li&gt;各种异常处理（签名错、时间戳过期、解密失败）&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;单看每一块都不复杂，但组合在一起就有点烦。&lt;br&gt;
而且这些逻辑和业务关系不大，却不得不散落在代码里。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;我做的小工具&lt;/h2&gt;
&lt;p&gt;为了省事，我干脆把它们封装成了前后端两个库：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;前端是一个 npm 包&lt;/li&gt;
&lt;li&gt;后端是一个 PHP 的 Composer 库&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;目标就是：&lt;strong&gt;用起来跟普通请求没什么两样，但底层自动帮你做了加解密和校验&lt;/strong&gt;。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;用法示例&lt;/h2&gt;
&lt;p&gt;前端：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install hejunjie-encrypted-request
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import { encryptRequest } from &quot;hejunjie-encrypted-request&quot;;

# 不建议在代码里写死公钥，建议通过读取文件来获取公钥字符串
const encrypted = encryptRequest({ name: &quot;张三&quot; }, {
  publicKey: &quot;-----BEGIN PUBLIC KEY-----...-----END PUBLIC KEY-----&quot;,
});

request.post(&quot;/api/user/info&quot;, encrypted)
  .then(res =&gt; console.log(res));
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;后端：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;composer require hejunjie/encrypted-request
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;use Hejunjie\EncryptedRequest\EncryptedRequestHandler;

// 自行在中间件或方法前完成请求参数的获取
$param = $_POST;

// 同样不建议代码里写死私钥
// 要么私钥作为 RSA_PRIVATE_KEY 放在.env里（此方法就无需再传递$config）
// 要么直接读取文件作为配置传递
$config = [
    &apos;RSA_PRIVATE_KEY&apos; =&gt; file_get_contents(private_key.pem)
];
$handler = new EncryptedRequestHandler($config);
$data = $encrypted-&gt;handle($param[&apos;en_data&apos;] ?? &apos;&apos;, $param[&apos;enc_payload&apos;] ?? &apos;&apos;, $param[&apos;timestamp&apos;] ?? &apos;&apos;, $param[&apos;sign&apos;] ?? &apos;&apos;);

print_r($data); // [&apos;name&apos; =&gt; &apos;张三&apos;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;开发者只需要像平时一样写请求，库会自动处理 AES/RSA 加解密、签名、时间戳校验、异常。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;适合的场景&lt;/h2&gt;
&lt;p&gt;这个方案当然不是替代 HTTPS，而是作为&lt;strong&gt;额外的一层保护&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;内部系统，不希望接口被随便重放/模拟&lt;/li&gt;
&lt;li&gt;中小项目，对安全有点额外要求，但不想上很重的安全框架&lt;/li&gt;
&lt;li&gt;想快速把 RSA+AES 混合加密落地，而不用自己重复造轮子&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;仓库地址&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;前端仓库：&lt;a href=&quot;https://github.com/zxc7563598/npm-encrypted-request&quot;&gt;npm-encrypted-request&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;后端仓库：&lt;a href=&quot;https://github.com/zxc7563598/php-encrypted-request&quot;&gt;php-encrypted-request&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;希望能帮到和我一样遇到类似需求的人。&lt;/p&gt;</content:encoded><h:img src="/_astro/php.DoApYH8-.png"/><enclosure url="/_astro/php.DoApYH8-.png"/></item><item><title>订单号老是撞车？我写了个通用 PHP ID 生成器</title><link>https://hejunjie.life/blog/697aafe5</link><guid isPermaLink="true">https://hejunjie.life/blog/697aafe5</guid><description>写项目时总要生成各种 ID：订单号、日志标识、用户编号……为了解决这些小烦恼，我做了一个 PHP ID 生成器，支持雪花算法、时间戳、UUID 等多种方式，还能自定义扩展，用起来简单，也方便以后维护</description><pubDate>Sat, 23 Aug 2025 01:02:10 GMT</pubDate><content:encoded>&lt;p&gt;在日常开发里，我们经常会遇到这种情况：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;需要给&lt;strong&gt;订单&lt;/strong&gt;生成唯一编号；&lt;/li&gt;
&lt;li&gt;想给&lt;strong&gt;日志&lt;/strong&gt;或者&lt;strong&gt;资源&lt;/strong&gt;加个标识；&lt;/li&gt;
&lt;li&gt;或者需要一个&lt;strong&gt;不会重复的 ID&lt;/strong&gt;，用作数据库主键。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;一开始，我也用过 &lt;code&gt;time()&lt;/code&gt; 拼接随机数、或者 &lt;code&gt;uniqid()&lt;/code&gt;。&lt;br&gt;
这些方案在小项目里够用，但一旦放到并发稍微高点的业务里，就会出现各种问题：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;​&lt;code&gt;time()&lt;/code&gt; 很容易撞车（同一毫秒可能生成多个）；&lt;/li&gt;
&lt;li&gt;​&lt;code&gt;uniqid()&lt;/code&gt; 看上去独特，其实也可能重复，而且格式不太好看；&lt;/li&gt;
&lt;li&gt;有些场景希望 ID &lt;strong&gt;可读&lt;/strong&gt;，比如订单号，最好一眼能分辨。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;久而久之，我在不同项目里反复造轮子，干脆就写了一个通用的小工具，把常见的 ID 生成方式都封装起来了。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;这个工具能做什么？&lt;/h2&gt;
&lt;p&gt;它支持几种常见的生成策略：&lt;/p&gt;
&lt;h3&gt;1. Snowflake（雪花算法）&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;经典的分布式 ID 算法；&lt;/li&gt;
&lt;li&gt;生成的 ID 在高并发下也能保持唯一。&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;use Hejunjie\IdGenerator\IdGenerator;

$generator = new IdGenerator::make(&apos;snowflake&apos;)
$id = $generator-&gt;generate();

echo $id; // 233953299479035905
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;还能解析出来生成时间、机器 ID 等信息，排查问题时很方便。&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;2. Timestamp（时间戳 ID）&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;由时间戳+序列号构成；&lt;/li&gt;
&lt;li&gt;可以加前缀，比如生成订单号：&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;use Hejunjie\IdGenerator\IdGenerator;

$generator = new IdGenerator::make(&apos;timestamp&apos;, [&apos;prefix&apos; =&gt; &apos;ORD&apos;]);
$id = $generator-&gt;generate();

echo $id; // ORD1755778813238294503
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3&gt;3. Readable（可读 ID）&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;格式类似：&lt;code&gt;ORD-20250822-abcdef12&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;适合展示给用户，更容易记忆。&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;use Hejunjie\IdGenerator\IdGenerator;

$generator = new IdGenerator::make(&apos;readable&apos;, [&apos;prefix&apos; =&gt; &apos;ORD&apos;, &apos;randomLength&apos; =&gt; 6]);
$id = $generator-&gt;generate();

echo $id; // ORD-2025-08-21-7ABP01
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3&gt;4. UUID&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;熟悉的 UUID v1/v4；&lt;/li&gt;
&lt;li&gt;通用，跨系统时尤其常见。&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;use Hejunjie\IdGenerator\IdGenerator;

$generator = new IdGenerator::make(&apos;uuid&apos;, [&apos;version&apos; =&gt; &apos;v4&apos;]);
$id = $generator-&gt;generate();

echo $id; // ad0f5dfc-4a3d-4c2a-853b-aa3a3d9062aa
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3&gt;5. 自定义策略&lt;/h3&gt;
&lt;p&gt;如果你想要特别的格式，比如 &lt;code&gt;USER-随机数&lt;/code&gt;，也能自己实现。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;use Hejunjie\IdGenerator\Contracts\Generator;
use Hejunjie\IdGenerator\IdGenerator;

class MyCustomGenerator implements Generator
{
    public function __construct(private string $prefix = &apos;MY&apos;) {}

    public function generate(): string
    {
        return $this-&gt;prefix . &apos;-&apos; . random_int(1000, 9999);
    }
    public function parse(string $id): array
    {
        return [&apos;id&apos; =&gt; $id];
    }
}

IdGenerator::registerStrategy(&apos;custom&apos;, function (array $config) {
    return new MyCustomGenerator($config[&apos;prefix&apos;] ?? &apos;MY&apos;);
});

$generator = new IdGenerator::make(&apos;custom&apos;, [&apos;prefix&apos; =&gt; &apos;ORD&apos;]);
$id = $generator-&gt;generate();

echo $id; // ORD-4128
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;为什么要做这个库？&lt;/h2&gt;
&lt;p&gt;没有什么“高大上”的理由，就是因为自己项目里老是遇到这种需求：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;想要一个&lt;strong&gt;统一&lt;/strong&gt;的生成方式，避免东拼西凑；&lt;/li&gt;
&lt;li&gt;想要&lt;strong&gt;可扩展&lt;/strong&gt;，方便以后加新的策略；&lt;/li&gt;
&lt;li&gt;想要&lt;strong&gt;轻量&lt;/strong&gt;，直接安装就能用，不依赖庞大的框架。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;于是就写了这么个东西，后来觉得或许对别人也有用，就开源了。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;安装方式&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;composer require hejunjie/id-generator
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;最后&lt;/h2&gt;
&lt;p&gt;这个小工具库目前还是 v1.0.0，功能比较基础。&lt;br&gt;
如果你在项目里也有类似需求，可以拿来试试看。&lt;br&gt;
要是你有更好的想法（比如加 ULID、新的生成算法等），非常欢迎提 PR 或者 Issue，一起完善。&lt;/p&gt;
&lt;p&gt;项目地址 👉 &lt;a href=&quot;https://github.com/zxc7563598/php-id-generator&quot;&gt;zxc7563598/php-id-generator&lt;/a&gt;&lt;/p&gt;</content:encoded><h:img src="/_astro/php.DoApYH8-.png"/><enclosure url="/_astro/php.DoApYH8-.png"/></item><item><title>舰长积分商城和弹幕机器人系统的手动部署教程</title><link>https://hejunjie.life/blog/b80f6d1a</link><guid isPermaLink="true">https://hejunjie.life/blog/b80f6d1a</guid><description>在这篇详细教程中，将教你如何从零开始部署哔哩哔哩直播机器人和积分商城。无论你是初学者还是开发者，都能轻松跟随步骤搭建自己的直播机器人，实现弹幕监控、自动答谢、定时广告等功能，并让观众通过消费获得积分选择礼物。提供从购买服务器到部署项目的完整指南，让你快速上手</description><pubDate>Sun, 17 Aug 2025 11:47:11 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;有感觉到文章很乱吗？虽然不知道你怎么想但反正我是有点感觉到了。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;不同时间出了好几个教程管理起来真的很麻烦。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;我把所有相关的内容放到了一个文档中：&lt;a href=&quot;/danmusuite&quot;&gt;点击前往&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;今天给大家带来的是&lt;strong&gt;舰长积分商城和弹幕机器人系统的手动部署教程&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;之前我出过一个“一键部署”的版本。&lt;br&gt;
但是有不少小伙伴反馈说，不太想用 Docker，或者想直接在自己的设备上部署，所以更希望能一步一步手动安装。&lt;/p&gt;
&lt;p&gt;这次我就从零开始，带大家完整走一遍手动部署的流程。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;功能简介&lt;/h2&gt;
&lt;p&gt;这个系统主要有两个功能：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;积分商城&lt;/strong&gt;：舰长上舰后自动记录积分，积分可在商城里兑换奖品；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;弹幕机器人&lt;/strong&gt;：支持自动感谢礼物、关注、进房、分享，还能播报大乱斗对手房间情况。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所有数据都在你自己的服务器里，不需要担心审核或数据泄露，也不会有任何收费问题，完全免费。唯一的成本就是准备一台云服务器。&lt;/p&gt;
&lt;p&gt;当然，如果你完全不懂代码，更推荐先看我之前的“一键部署”教程，会更省事。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;如果你选择手动部署，不管你是折腾群晖也好，在Windows环境下跑虚拟机也罢，我默认你是一个多少懂一点基础的朋友。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;记得在部署的过程中解决科学上网的问题，因为代码托管也好，项目中一些扩展的安装也好，难免会用到大陆以外的资源。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;如果遇到问题我非常乐意在空闲时间帮大家解决问题，但是网络问题这种我确实没有什么好办法，这个东西比较敏感，也不太好教 🙏&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h2&gt;Step 1：安装宝塔面板&lt;/h2&gt;
&lt;p&gt;首先，我们需要在服务器上安装 &lt;a href=&quot;https://www.bt.cn/new/download.html&quot;&gt;宝塔面板&lt;/a&gt;。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://hejunjie-blog.oss-rg-china-mainland.aliyuncs.com/b80f6d1a/001-20250817165903-pvwsw7o.png&quot; alt=&quot;宝塔下载页面&quot;&gt;&lt;/p&gt;
&lt;p&gt;安装完成后，宝塔会显示登录地址、用户名和密码。用这些信息登录，就能进入服务器的管理面板。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://hejunjie-blog.oss-rg-china-mainland.aliyuncs.com/b80f6d1a/002-20250817170113-9gp7ez5.png&quot; alt=&quot;服务器安装宝塔完成&quot;&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Step 2：安装运行环境&lt;/h2&gt;
&lt;p&gt;登录宝塔后台，进入 &lt;strong&gt;软件商店&lt;/strong&gt;，依次安装：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Nginx&lt;/strong&gt;（默认版本）；&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://hejunjie-blog.oss-rg-china-mainland.aliyuncs.com/b80f6d1a/003-20250817170329-jyiqs6n.png&quot; alt=&quot;安装 Nginx&quot;&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;PHP 8.2&lt;/strong&gt;；&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://hejunjie-blog.oss-rg-china-mainland.aliyuncs.com/b80f6d1a/004-20250817170427-5w1oqix.png&quot; alt=&quot;安装 PHP&quot;&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;MySQL 8.0&lt;/strong&gt;（注意不要用默认版本）；&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://hejunjie-blog.oss-rg-china-mainland.aliyuncs.com/b80f6d1a/005-20250817170527-jllysmh.png&quot; alt=&quot;安装 MySQL&quot;&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Redis&lt;/strong&gt;（默认版本即可）&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://hejunjie-blog.oss-rg-china-mainland.aliyuncs.com/b80f6d1a/006-20250817170626-m9766wd.png&quot; alt=&quot;安装 Redis&quot;&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;安装需要一些时间，大家耐心等待，完成后，如下图所示。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://hejunjie-blog.oss-rg-china-mainland.aliyuncs.com/b80f6d1a/007-20250817172150-fpgmnsf.png&quot; alt=&quot;完成安装后的样子&quot;&gt;&lt;/p&gt;
&lt;p&gt;完成后，还需要做以下配置：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;在 PHP 设置中安装扩展：&lt;code&gt;redis&lt;/code&gt;、&lt;code&gt;event&lt;/code&gt;；&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://hejunjie-blog.oss-rg-china-mainland.aliyuncs.com/b80f6d1a/008-20250817172341-i06415z.png&quot; alt=&quot;安装扩展，event在下面&quot;&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;在配置文件中搜索：&lt;code&gt;disable_functions&lt;/code&gt; ，内容替换为：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;disable_functions = passthru,system,chroot,chgrp,chown,ini_alter,ini_restore,dl,openlog,syslog,readlink,symlink,popepassthru
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://hejunjie-blog.oss-rg-china-mainland.aliyuncs.com/b80f6d1a/017-20250817180614-ofulqpw.png&quot; alt=&quot;记得保存配置&quot;&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;在 &lt;code&gt;pecl扩展安装&lt;/code&gt; 处安装 &lt;code&gt;brotli&lt;/code&gt;​&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://hejunjie-blog.oss-rg-china-mainland.aliyuncs.com/b80f6d1a/019-20250817184319-iopbz2k.png&quot; alt=&quot;安装 brotli&quot;&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;进入数据库，修改 root 密码为一个你自己能记住的值。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://hejunjie-blog.oss-rg-china-mainland.aliyuncs.com/b80f6d1a/009-20250817175121-hh4m48b.png&quot; alt=&quot;修改数据库密码&quot;&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;Step 3：创建数据库&lt;/h2&gt;
&lt;p&gt;在终端输入：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;mysql -u root -p
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://hejunjie-blog.oss-rg-china-mainland.aliyuncs.com/b80f6d1a/010-20250817175252-sntstjp.png&quot; alt=&quot;登录 MySQL&quot;&gt;&lt;/p&gt;
&lt;p&gt;输入刚刚设置的密码后，执行以下命令创建数据库：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE DATABASE bilibili_danmu CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://hejunjie-blog.oss-rg-china-mainland.aliyuncs.com/b80f6d1a/011-20250817175347-zsgq8ev.png&quot; alt=&quot;创建数据库&quot;&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Step 4：下载项目&lt;/h2&gt;
&lt;p&gt;在宝塔后台打开 &lt;strong&gt;文件 → 终端&lt;/strong&gt;，输入：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git clone https://github.com/zxc7563598/php-bilibili-danmu
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://hejunjie-blog.oss-rg-china-mainland.aliyuncs.com/b80f6d1a/012-20250817175517-2utwkhu.png&quot; alt=&quot;下载项目&quot;&gt;&lt;/p&gt;
&lt;p&gt;刷新页面后，可以看到多了一个 &lt;code&gt;php-bilibili-danmu&lt;/code&gt; 文件夹。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://hejunjie-blog.oss-rg-china-mainland.aliyuncs.com/b80f6d1a/013-20250817175554-5b2h9re.png&quot; alt=&quot;文件列表&quot;&gt;&lt;/p&gt;
&lt;p&gt;进入该文件夹，新建 &lt;code&gt;.env&lt;/code&gt; 文件（前面有点）。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://hejunjie-blog.oss-rg-china-mainland.aliyuncs.com/b80f6d1a/014-20250817175732-hhirzk1.png&quot; alt=&quot;新建.env文件&quot;&gt;&lt;br&gt;
然后打开 &lt;code&gt;.env.example&lt;/code&gt;，复制内容到 &lt;code&gt;.env&lt;/code&gt; 并修改：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;系统服务地址：&lt;code&gt;服务器IP:7777&lt;/code&gt;​&lt;/li&gt;
&lt;li&gt;加密配置：随意输入 16 位字母或数字（数字密钥、初始向量同样 16 位）&lt;/li&gt;
&lt;li&gt;主密钥：随意输入 32 位字母或数字&lt;/li&gt;
&lt;li&gt;商城前台访问地址：&lt;code&gt;服务器IP:5177&lt;/code&gt;​&lt;/li&gt;
&lt;li&gt;项目监听端口：&lt;code&gt;7776&lt;/code&gt;​&lt;/li&gt;
&lt;li&gt;API 密钥：随意输入 32 位字母或数字&lt;/li&gt;
&lt;li&gt;数据库配置：数据库名（bilibili_danmu）、用户名（root）、密码（你修改的 root 密码）&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;保存后，配置完成。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://hejunjie-blog.oss-rg-china-mainland.aliyuncs.com/b80f6d1a/015-20250817180201-8ehszca.png&quot; alt=&quot;不需要照着抄，根据自己服务器的信息修改&quot;&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Step 5：安装依赖并启动&lt;/h2&gt;
&lt;p&gt;在终端执行（一行一行执行）：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;composer install

php vendor/bin/phinx migrate -e development

php start.php start -d
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样依赖安装、数据库构建、项目启动就完成了。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://hejunjie-blog.oss-rg-china-mainland.aliyuncs.com/b80f6d1a/016-20250817180307-4crrtlx.png&quot; alt=&quot;一行一行执行命令&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://hejunjie-blog.oss-rg-china-mainland.aliyuncs.com/b80f6d1a/020-20250817184458-sdw09ft.png&quot; alt=&quot;项目启动后的样子&quot;&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Step 6：配置机器人站点&lt;/h2&gt;
&lt;p&gt;进入宝塔后台 → 网站 → 添加站点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;域名：&lt;code&gt;服务器IP:7777&lt;/code&gt;​&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;根目录：&lt;code&gt;php-bilibili-danmu&lt;/code&gt;​&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://hejunjie-blog.oss-rg-china-mainland.aliyuncs.com/b80f6d1a/021-20250817184631-unra297.png&quot; alt=&quot;配置站点&quot;&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;创建后，进入站点设置 → 配置文件，在顶部添加：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-nginx&quot;&gt;upstream bilibilidanmuji {
    server 127.0.0.1:7776;
    keepalive 10240;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://hejunjie-blog.oss-rg-china-mainland.aliyuncs.com/b80f6d1a/022-20250817184800-dmoaf58.png&quot; alt=&quot;配置 Nginx&quot;&gt;&lt;/p&gt;
&lt;p&gt;保存后，再进入伪静态，添加：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-nginx&quot;&gt;location ^~ / {
  proxy_set_header Host $http_host;
  proxy_set_header X-Forwarded-For $remote_addr;
  proxy_set_header X-Forwarded-Proto $scheme;
  proxy_set_header X-Real-IP $remote_addr;
  proxy_http_version 1.1;
  proxy_set_header Connection &quot;&quot;;
  if (!-f $request_filename){
    proxy_pass http://bilibilidanmuji;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://hejunjie-blog.oss-rg-china-mainland.aliyuncs.com/b80f6d1a/023-20250817184850-ga6abqd.png&quot; alt=&quot;配置伪静态&quot;&gt;&lt;/p&gt;
&lt;p&gt;最后在网站目录设置中：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;关闭防跨站攻击；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;将运行目录改为 &lt;code&gt;public&lt;/code&gt;；&lt;br&gt;
保存即可。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://hejunjie-blog.oss-rg-china-mainland.aliyuncs.com/b80f6d1a/024-20250817184940-suuuu2w.png&quot; alt=&quot;配置访问目录&quot;&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这时，访问 &lt;code&gt;服务器IP:7777&lt;/code&gt; 就能进入机器人控制台。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Step 7：配置积分商城&lt;/h2&gt;
&lt;p&gt;宝塔后台 → 网站 → Node项目 安装Node：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://hejunjie-blog.oss-rg-china-mainland.aliyuncs.com/b80f6d1a/026-20250817191217-rakinz8.png&quot; alt=&quot;安装 NodeJs&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://hejunjie-blog.oss-rg-china-mainland.aliyuncs.com/b80f6d1a/027-20250817192945-dku1yw7.png&quot; alt=&quot;先更新列表获取最新版本，记得安装官方源&quot;&gt;&lt;/p&gt;
&lt;p&gt;设置命令行版本：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://hejunjie-blog.oss-rg-china-mainland.aliyuncs.com/b80f6d1a/028-20250817192633-5j4syza.png&quot; alt=&quot;设置命令版本，不然无法构建&quot;&gt;&lt;/p&gt;
&lt;p&gt;进入机器人控制台，点击 &lt;strong&gt;构建商城&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://hejunjie-blog.oss-rg-china-mainland.aliyuncs.com/b80f6d1a/025-20250817191243-dcew9c9.png&quot; alt=&quot;服务器IP:7777即可访问&quot;&gt;&lt;/p&gt;
&lt;p&gt;看到这样的内容说明成功：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://hejunjie-blog.oss-rg-china-mainland.aliyuncs.com/b80f6d1a/029-20250817191346-234i81y.png&quot; alt=&quot;完成积分商城构建&quot;&gt;&lt;/p&gt;
&lt;p&gt;然后在宝塔后台 → 网站 → 新建站点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;域名：&lt;code&gt;服务器IP:5177&lt;/code&gt;​&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;根目录：&lt;code&gt;php-bilibili-danmu/public/shop/dist&lt;/code&gt;​&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;PHP 版本：纯静态&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://hejunjie-blog.oss-rg-china-mainland.aliyuncs.com/b80f6d1a/030-20250817193327-k8e9sip.png&quot; alt=&quot;创建积分商城站点&quot;&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;创建后，进入伪静态，填入：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-nginx&quot;&gt;location / {
  try_files $uri $uri/ /index.html;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;保存后，访问 &lt;code&gt;服务器IP:5177&lt;/code&gt; 即可进入积分商城。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://hejunjie-blog.oss-rg-china-mainland.aliyuncs.com/b80f6d1a/031-20250817193424-yz1f9o3.png&quot; alt=&quot;保存积分商城站点伪静态&quot;&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;🎉 至此，手动部署就全部完成啦！&lt;br&gt;
机器人和积分商城都可以正常使用，大家可以根据需要进行进一步配置。&lt;/p&gt;
&lt;h2&gt;关于一键部署&lt;/h2&gt;
&lt;p&gt;可能不是最完美的方案，但它一定是对新手最友好的选择：一键配置，命令执行后静待完成，项目会自动拉取、配置并保持更新，全程无需额外操作。&lt;/p&gt;
&lt;p&gt;在你的服务器上执行：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;curl -fsSL https://bilibili-danmu-scripts.oss-cn-hongkong.aliyuncs.com/install-docker.sh | bash
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;没有开发基础的用户&lt;/strong&gt; 请确保您使用的是 &lt;a href=&quot;https://cn.aliyun.com&quot;&gt;阿里云&lt;/a&gt; 位于 &lt;strong&gt;香港&lt;/strong&gt; 地域的 &lt;strong&gt;Ubuntu 24.04 64 位&lt;/strong&gt; 版本的服务器。这是一套经过验证的环境，能最大程度减少部署问题&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;其他云服务商或系统版本未做兼容性测试，不排除出现问题的可能性。如果你遇到问题，可以来问我，我乐意帮忙，全当交个朋友。但不保证解决。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;一键部署脚本写得很简单，不依赖复杂逻辑，有动手能力的用户完全可以自行调整环境。完全不懂的朋友，建议按推荐配置来，别自找麻烦。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;不会购买服务器看下方「一些问题」中的「关于购买服务器」&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;购买服务器不知道如何使用的看下方「一些问题」中的「购买了服务器要如何使用」&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;关于购买服务器&lt;/h2&gt;
&lt;h3&gt;为什么要购买服务器？&lt;/h3&gt;
&lt;p&gt;这不是一个传统意义上的“机器人”项目。如果你只是需要一个弹幕助手，确实可以直接在自己的电脑上运行，直播时连上直播间，下播后关掉就行。&lt;/p&gt;
&lt;p&gt;但这个项目是一个积分商城，实质上就是你自己的一个网站。它需要 24 小时持续运行，并且对外开放，让观众随时都能访问和使用。所以，和普通机器人不同，它需要部署在一台能够长期在线、稳定对外服务的服务器上。&lt;/p&gt;
&lt;h3&gt;如何购买服务器？&lt;/h3&gt;
&lt;p&gt;前往 &lt;a href=&quot;https://ecs.console.aliyun.com/home&quot;&gt;阿里云云服务器 ECS 控制台&lt;/a&gt; → 点击「创建实例」 → 选择合适的配置进行购买。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://hejunjie-blog.oss-rg-china-mainland.aliyuncs.com/b06795f9/00004.png#pic_center&quot; alt=&quot;配置图，可按此图配置购买&quot;&gt;&lt;/p&gt;
&lt;p&gt;以下是推荐配置说明：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;地域&lt;/strong&gt;：请选择 &lt;strong&gt;中国香港&lt;/strong&gt;（具体原因详见下方章节 “为什么选择香港服务器”）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;配置&lt;/strong&gt;：最低建议选择 &lt;code&gt;2 vCPU + 2 GiB 内存&lt;/code&gt;​。计算型、共享型等都可，&lt;strong&gt;不推荐“突发性能实例”&lt;/strong&gt; ，这类机型在大多数时间会限制 CPU 性能，影响使用体验。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;系统镜像&lt;/strong&gt;：请选择 &lt;code&gt;Ubuntu 20.04 64位&lt;/code&gt;​，我所有的测试与部署都基于此版本，兼容性和稳定性最好。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;其他配置可根据个人需求自行选择，不过如果你是第一次使用，建议直接参考上图进行配置，省事也稳妥。&lt;/p&gt;
&lt;h3&gt;关于为什么要选择香港服务器&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;备案麻烦 &amp;#x26; 使用限制：&lt;/strong&gt;&lt;br&gt;
大陆地区的服务器对外提供互联网服务时，必须完成备案和一系列合规流程，流程繁琐，还会受到一定的监管。相比之下，香港属于中国，却&lt;strong&gt;不需要备案&lt;/strong&gt;，开箱即用、稳定省事。&lt;br&gt;
至于日本、韩国等境外服务器，由于&lt;strong&gt;科学上网问题&lt;/strong&gt;，连接经常不稳定，不推荐新手使用。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;代码托管在 GitHub：&lt;/strong&gt;&lt;br&gt;
如果你选择一键部署，服务器需要从 GitHub 拉取代码，而 GitHub 并不在国内。&lt;br&gt;
&lt;strong&gt;大陆服务器与个人一样也会受到网络限制&lt;/strong&gt;，可能导致部署失败。
&lt;blockquote&gt;
&lt;p&gt;小提示：如果你打算在自家的服务器、NAS 或虚拟机中部署，请务必先解决访问 GitHub 的问题。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;实用角度考虑：&lt;/strong&gt;&lt;br&gt;
其实用国内服务器也不是不能用，就是容易自找麻烦。&lt;br&gt;
阿里云香港的稳定性不错，部署项目之外还能顺便搭个梯子。虽然 ChatGPT 屏蔽了香港 IP，但&lt;strong&gt;看点其他内容、搭建加速服务还是很香的&lt;/strong&gt;，明码标价、速度稳定，视频都能流畅播放。
&lt;blockquote&gt;
&lt;p&gt;如果你不懂怎么搭梯子，欢迎私聊，我可以教你。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;购买了服务器要如何使用&lt;/h3&gt;
&lt;p&gt;可以通过 FinalShell 远程连接直播间：&lt;a href=&quot;https://www.hostbuf.com/t/988.html&quot;&gt;点击下载&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://hejunjie-blog.oss-rg-china-mainland.aliyuncs.com/b06795f9/00011.png#pic_center&quot; alt=&quot;如何连接服务器&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://hejunjie-blog.oss-rg-china-mainland.aliyuncs.com/b06795f9/00012.png#pic_center&quot; alt=&quot;连接完成后运行一键安装脚本即可&quot;&gt;&lt;/p&gt;
&lt;h2&gt;关于怎么才能让访问地址变成网址而不是IP&lt;/h2&gt;
&lt;p&gt;需要购买域名进行绑定&lt;/p&gt;
&lt;h3&gt;为什么需要域名？&lt;/h3&gt;
&lt;p&gt;没有域名也能访问你的网站，正常情况下就是通过 &lt;code&gt;http://你的服务器IP:7777&lt;/code&gt;​ 来访问后台管理，商城则是 &lt;code&gt;http://你的服务器IP:5177&lt;/code&gt;​。&lt;/p&gt;
&lt;p&gt;这当然可以用，如果你不介意链接不好看，也可以直接生成二维码发给观众扫码，访问是没有任何问题的。&lt;/p&gt;
&lt;p&gt;但从使用体验来说，IP 地址链接就像毛坯房一样：&lt;br&gt;
丑、不好记、不专业、不安全。&lt;/p&gt;
&lt;p&gt;就像百度的 IP 地址是 &lt;code&gt;http://39.156.70.46&lt;/code&gt;​，你能访问它没错，但肯定没人会去记这个地址。相比之下，&lt;code&gt;https://baidu.com&lt;/code&gt;​ 简洁、有辨识度、安全性也更高。&lt;/p&gt;
&lt;p&gt;所以，&lt;strong&gt;域名的意义不只是“能访问”，而是让你的项目看起来像个“成品”而不是“内测工具”&lt;/strong&gt; 。而且现在域名注册成本也不高，是值得的投入。&lt;/p&gt;
&lt;h3&gt;如何购买域名？&lt;/h3&gt;
&lt;p&gt;域名注册的平台有很多，比如 &lt;a href=&quot;https://www.godaddy.com&quot;&gt;GoDaddy&lt;/a&gt; 和 &lt;a href=&quot;https://dc.console.aliyun.com&quot;&gt;阿里云&lt;/a&gt; 都是业内比较成熟、可靠的选择。&lt;/p&gt;
&lt;p&gt;如果你已经在阿里云购买了服务器，那直接在阿里云顺手把域名也买了会更方便。购买流程不复杂：&lt;br&gt;
搜索你想要的域名，看看有没有被注册，没被注册就可以直接下单购买。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;注意：&lt;/strong&gt; 在阿里云购买域名时需要先完成&lt;strong&gt;实名认证&lt;/strong&gt;，流程不麻烦，按照提示提交资料即可。不需要备案，只是做个身份验证。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;另外提醒一下：&lt;br&gt;
如果你的服务器不是香港而是中国大陆地区的，那我通常&lt;strong&gt;不建议你单独买域名&lt;/strong&gt;。因为&lt;strong&gt;域名如果解析到大陆服务器，必须完成 ICP 备案&lt;/strong&gt;，这个流程需要向工信部提交材料，耗时大约 20 个工作日左右。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;而且如果你是个人备案，只能用于“博客类”网站，像这种积分商城类项目是无法备案的，必须使用企业主体才能通过。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;购买域名后如何使用&lt;/h3&gt;
&lt;p&gt;购买域名后，请前往 &lt;a href=&quot;https://dc.console.aliyun.com/#/domain-list/all&quot;&gt;阿里云域名列表&lt;/a&gt;：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;找到你购买的域名 → 点击“操作” → 选择“解析” → 进入添加记录页面&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;img src=&quot;https://hejunjie-blog.oss-rg-china-mainland.aliyuncs.com/b06795f9/00009.png#pic_center&quot; alt=&quot;添加记录页面&quot;&gt;&lt;/p&gt;
&lt;p&gt;在“添加记录”页面中，大部分设置（如记录类型、解析线路、TTL）保持默认即可，只需要关注以下两个字段：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;主机记录&lt;/strong&gt;：即你想绑定的子域名前缀。例如你的域名是 &lt;code&gt;hejunjie.life&lt;/code&gt;​，这里填写 &lt;code&gt;asd&lt;/code&gt;​，那最终用户访问的地址就是 &lt;code&gt;asd.hejunjie.life&lt;/code&gt;​&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;记录值&lt;/strong&gt;：填写你服务器的公网 IP 地址&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;你需要添加 &lt;strong&gt;两个解析记录&lt;/strong&gt; 指向你的服务器：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;一个用于访问 &lt;strong&gt;机器人后台&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;一个用于访问 &lt;strong&gt;积分商城&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;⚠️ 域名解析通常需要几分钟时间才能生效，建议等待 &lt;strong&gt;10 分钟左右&lt;/strong&gt; 确保解析成功。&lt;/p&gt;
&lt;p&gt;✅ 解析成功后，登陆服务器配置域名和 SSL 证书即可&lt;/p&gt;
&lt;h3&gt;如何配置服务器域名和 SSL 证书&lt;/h3&gt;
&lt;h4&gt;一键部署版&lt;/h4&gt;
&lt;p&gt;解析完成后，登录你的服务器，执行以下命令完成自动配置（包括 Nginx 和 SSL）：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;curl -fsSL https://bilibili-danmu-scripts.oss-cn-hongkong.aliyuncs.com/setup-nginx-ssl.sh | bash -s 后台地址 积分商城地址 你的邮箱
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;之后完成后续步骤即可&lt;/strong&gt;&lt;/p&gt;
&lt;h4&gt;手动部署版&lt;/h4&gt;
&lt;p&gt;前往宝塔面板 -&gt; 网站，可以看到手动部署时你设置的机器人站点（后台站点）跟积分商城站点&lt;/p&gt;
&lt;p&gt;对应的站点点击设置，在域名位置（原本手动部署中写服务器IP:7777跟服务器IP:5177的位置）添加解析的网站地址&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;后台站点添加后台地址，积分商城添加积分商城地址&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;“后台地址”和“积分商城地址”就是你刚才解析的域名，例如：&lt;code&gt;asd.hejunjie.life&lt;/code&gt;​&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;添加完成后选择 &lt;strong&gt;SSL&lt;/strong&gt;，切换到 &lt;strong&gt;Let&apos;s Encrypt&lt;/strong&gt; 标签页&lt;/p&gt;
&lt;p&gt;勾选添加的域名，申请证书，即可完成配置&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;之后完成后续步骤即可&lt;/strong&gt;&lt;/p&gt;
&lt;h4&gt;后续步骤&lt;/h4&gt;
&lt;p&gt;执行成功后，进入机器人后台控制台 → 打开「系统配置」：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://hejunjie-blog.oss-rg-china-mainland.aliyuncs.com/b06795f9/00010.png#pic_center&quot; alt=&quot;机器人控制台系统配置页面&quot;&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;修改 &lt;code&gt;项目地址&lt;/code&gt;​ 为：&lt;code&gt;https://后台的地址&lt;/code&gt;​&lt;/li&gt;
&lt;li&gt;修改 &lt;code&gt;商城地址&lt;/code&gt;​ 为：&lt;code&gt;https://积分商城的地址&lt;/code&gt;​&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;“后台地址”和“积分商城地址”就是你刚才解析的域名，例如：&lt;code&gt;asd.hejunjie.life&lt;/code&gt;​&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;例如你填的是 &lt;code&gt;asd.hejunjie.life&lt;/code&gt;​，那就改为：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;​&lt;code&gt;https://asd.hejunjie.life&lt;/code&gt;​&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;配置完成后，你就可以通过浏览器访问：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;控制台后台：&lt;code&gt;https://后台的地址&lt;/code&gt;​&lt;/li&gt;
&lt;li&gt;用户商城：&lt;code&gt;https://积分商城的地址&lt;/code&gt;​&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;关于独立后台&lt;/h2&gt;
&lt;p&gt;仓库地址：&lt;a href=&quot;https://github.com/zxc7563598/vue-bilibili-danmu-admin&quot;&gt;点击查看&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;现在的机器人实际上已经内置了一个原生后台，&lt;strong&gt;已可直接使用，此项目非刚需&lt;/strong&gt;。本项目的目标是为追求更强大后台功能的用户提供一个更完善的替代方案。&lt;/p&gt;
&lt;p&gt;由于在推荐服务器配置时出于成本考虑，建议使用较低配置的服务器（如 &lt;strong&gt;2 核 2G&lt;/strong&gt;）。这种配置在实际运行项目时并不会有明显问题，但如果尝试在服务器上直接构建本项目，性能压力会非常大，构建过程可能会失败甚至卡死。&lt;/p&gt;
&lt;p&gt;因此，我们将前端后台代码单独抽离到此仓库，&lt;strong&gt;建议用户在本地完成构建后再上传到服务器&lt;/strong&gt;。这并不是一项复杂的操作，欢迎动手尝试，如有困难也可以联系作者获取帮助。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;部署方式&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# 1. 下载项目
git clone https://github.com/zxc7563598/vue-bilibili-danmu-admin.git
# 然后你就会得到一个 vue-bilibili-danmu-admin 目录，这个就是整个项目了
# 前往这个目录


# 2. 安装 Node.js
# 请前往官网下载安装适合你系统的版本：https://nodejs.org/zh-cn
# 目的是为了可以在终端使用 npm 命令
# 过低版本的 NodeJs 的 ESM 模块在 Windows 上对绝对路径支持不完善，可能会出现问题
# 所以尽量不要去装过低的版本，起码要在 18+ 以上才不会有问题

# 3. 配置环境变量：复制 `.env.example` 为 `.env`
# 这里有很多朋友没搞明白
# 其实就是你下载下来的项目中，会存在一个 .env.example 文件，这个是配置的模板文件，教你怎么配置的
# 我们需要在 vue-bilibili-danmu-admin 中创建一个 .env 然后把 .env.example 文件里的内容复制一份过去
# .env 里面的内容需要手动替换
# 根据实际情况填写请求地址、密钥等信息，具体信息可在机器人控制台系统配置中查看。

# 4. 安装依赖 &amp;#x26; 构建项目
# 这里也有朋友翻过车，要在 vue-bilibili-danmu-admin 目录去执行这个命令哦
npm install
npm run build

# 执行后将生成 `dist/` 目录，即为最终可部署版本

# 自动部署：将此目录上传到 `/opt/bilibili-robots/php/public` 目录中，重新访问机器人控制台，即可自动切换到新版后台。
# 手动部署：将此目录上传到 `/www/wwwroot/php-bilibili-danmu/public` 目录中，重新访问机器人控制台，即可自动切换到新版后台。

# 使用新版后台的过程中，删除 `/项目目录/public/dist` 目录即可停用新版后台、切换回老版本后台
&lt;/code&gt;&lt;/pre&gt;</content:encoded><h:img src="/_astro/bilibili.p76c9pFG.jpg"/><enclosure url="/_astro/bilibili.p76c9pFG.jpg"/></item><item><title>如何让同事自己查数据？写一个零依赖 PHP SQL 查询工具就够了</title><link>https://hejunjie.life/blog/44994b49</link><guid isPermaLink="true">https://hejunjie.life/blog/44994b49</guid><description>一个零依赖的 PHP 数据查询小工具，让非技术同事在浏览器里自助执行 SQL，支持参数化配置，再也不用打断开发者查数据</description><pubDate>Sun, 17 Aug 2025 07:10:37 GMT</pubDate><content:encoded>&lt;p&gt;在公司里，运营、老板或者其他同事经常会来找我问数据。很多时候，一个简单的 SQL 就能解决问题，但他们从来不愿意学 SQL，也不想安装任何查询工具。每次被打断写代码，我都非常无奈。&lt;/p&gt;
&lt;p&gt;于是，我写了一个小工具——&lt;strong&gt;Data Query Tool&lt;/strong&gt;，让同事自己动手查数据，自己专心写代码，同时还能保证简单、快速、安全。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;&lt;strong&gt;问题&lt;/strong&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;同事不会 SQL，也不想学。&lt;/li&gt;
&lt;li&gt;安装数据库客户端工具成本高，不方便在每台电脑上部署，哪怕是 phpMyAdmin 他们都不愿意去了解。&lt;/li&gt;
&lt;li&gt;每次帮人查数据都很耗时间，打断了开发思路。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;我希望的是：&lt;strong&gt;让非技术同事可以在浏览器里直接点击，就得到他们想要的数据，最好还能导出 Excel，省的来找我要数据&lt;/strong&gt;。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;&lt;strong&gt;解决方案&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;我选择了用 PHP 写一个&lt;strong&gt;零依赖、无框架、单文件启动的查询工具&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;只要把代码放到服务器上（最好是公司内网），运行：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;php -S 0.0.0.0:8000
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;就可以通过浏览器访问 &lt;code&gt;http://服务器IP:8000&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;常用 SQL 配置成模板&lt;/strong&gt;，支持参数化：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;文本输入（input）&lt;/li&gt;
&lt;li&gt;日期选择（date）&lt;/li&gt;
&lt;li&gt;下拉选择（select）&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;同事直接选择 SQL 模板、填入参数，就能查询，不用写 SQL。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;内网部署安全可靠，零打扰开发流程。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;&lt;strong&gt;SQL 配置说明&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;工具使用 &lt;code&gt;config.json&lt;/code&gt; 来管理 SQL 模板，结构示例：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;sql_templates&quot;: [
    {
      &quot;name&quot;: &quot;查询所有用户&quot;,
      &quot;query&quot;: &quot;SELECT user_id as &apos;用户ID&apos;,phone as &apos;手机号&apos;,real_name as &apos;姓名&apos;,FROM_UNIXTIME(created_at) as &apos;创建时间&apos; from ch_users where deleted_at is null&quot;,
      &quot;query_params&quot;: {}
    },
    {
      &quot;name&quot;: &quot;根据手机号查询用户&quot;,
      &quot;query&quot;: &quot;SELECT user_id as &apos;用户ID&apos;,phone as &apos;手机号&apos;,real_name as &apos;姓名&apos;,FROM_UNIXTIME(created_at) as &apos;创建时间&apos; from ch_users where phone = {{phone}} and deleted_at is null&quot;,
      &quot;query_params&quot;: {
        &quot;phone&quot;: {
          &quot;type&quot;: &quot;input&quot;,
          &quot;description&quot;: &quot;手机号&quot;
        }
      }
    },
    {
      &quot;name&quot;: &quot;根据注册时间查询用户&quot;,
      &quot;query&quot;: &quot;SELECT user_id as &apos;用户ID&apos;,phone as &apos;手机号&apos;,real_name as &apos;姓名&apos;,FROM_UNIXTIME(created_at) as &apos;创建时间&apos; from ch_users where created_at &gt;= UNIX_TIMESTAMP({{start_time}}) and created_at &amp;#x3C;= UNIX_TIMESTAMP({{end_time}}) and deleted_at is null&quot;,
      &quot;query_params&quot;: {
        &quot;start_time&quot;: {
          &quot;type&quot;: &quot;date&quot;,
          &quot;description&quot;: &quot;开始时间&quot;
        },
        &quot;end_time&quot;: {
          &quot;type&quot;: &quot;date&quot;,
          &quot;description&quot;: &quot;结束时间&quot;
        }
      }
    },
    {
      &quot;name&quot;: &quot;根据订单状态搜索订单信息&quot;,
      &quot;query&quot;: &quot;SELECT borrow_sn as &apos;订单号&apos;,real_name as &apos;姓名&apos;,borrow_amount as &apos;合同金额&apos;,FROM_UNIXTIME(created_at) as &apos;签约时间&apos; from ch_borrows where `status` = {{status}} and deleted_at is null&quot;,
      &quot;query_params&quot;: {
        &quot;status&quot;: {
          &quot;type&quot;: &quot;select&quot;,
          &quot;options&quot;: [
            { &quot;value&quot;: &quot;5&quot;, &quot;label&quot;: &quot;拒绝签约&quot; },
            { &quot;value&quot;: &quot;6&quot;, &quot;label&quot;: &quot;取消签约&quot; },
            { &quot;value&quot;: &quot;9&quot;, &quot;label&quot;: &quot;签约失败&quot; },
            { &quot;value&quot;: &quot;11&quot;, &quot;label&quot;: &quot;已签约&quot; },
            { &quot;value&quot;: &quot;12&quot;, &quot;label&quot;: &quot;已完成&quot; }
          ],
          &quot;description&quot;: &quot;订单状态&quot;
        }
      }
    }
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;说明：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;​&lt;code&gt;query&lt;/code&gt; 中用 &lt;code&gt;{{参数名称}}&lt;/code&gt; 占位&lt;/li&gt;
&lt;li&gt;​&lt;code&gt;query_params&lt;/code&gt; 定义参数类型、说明、可选项&lt;/li&gt;
&lt;li&gt;支持 input / date / select 三种输入方式&lt;/li&gt;
&lt;li&gt;页面会根据参数类型自动生成相应控件，非技术同事也能操作&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h2&gt;&lt;strong&gt;使用示例&lt;/strong&gt;&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;将代码放到服务器或电脑&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git clone https://github.com/zxc7563598/data-query-tool.git
cd data-query-tool
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;启动 PHP 内置服务器&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;php -S 0.0.0.0:8000
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;浏览器访问&lt;/strong&gt;&lt;br&gt;
打开 &lt;code&gt;http://服务器IP:8000&lt;/code&gt;​&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;选择 SQL 模板&lt;/li&gt;
&lt;li&gt;填写或选择参数&lt;/li&gt;
&lt;li&gt;点击查询，即可显示结果&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;可选：编辑&lt;/strong&gt; &lt;strong&gt;​&lt;code&gt;config.json&lt;/code&gt;​&lt;/strong&gt;​&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;配置 SQL 模板&lt;/li&gt;
&lt;li&gt;配置模板参数类型和描述&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h2&gt;&lt;strong&gt;效果截图&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;​&lt;img src=&quot;article/44994b49/step1.png&quot; alt=&quot;构建配置&quot;&gt;​&lt;/p&gt;
&lt;p&gt;​&lt;img src=&quot;article/44994b49/step2.png&quot; alt=&quot;登录&quot;&gt;​&lt;/p&gt;
&lt;p&gt;​&lt;img src=&quot;article/44994b49/step3.png&quot; alt=&quot;查询&quot;&gt;​&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;总结&lt;/h2&gt;
&lt;p&gt;其实也没啥高大上的理由，我写这个工具就是因为——​&lt;strong&gt;老板和同事再也不会天天来烦我查数据了&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;他们想要啥，自己点点就行；我也不用被一行 SQL 打断思路。&lt;/p&gt;
&lt;p&gt;说白了，这东西就是 ​&lt;strong&gt;给自己图个清净&lt;/strong&gt;。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;如果你也遇到类似的场景，可以在评论区聊一聊。&lt;br&gt;
说不定我能帮你顺手做个小工具，也算是给我自己省点事 —— 毕竟我一个人可能发现不了那么多“被烦”的场景。&lt;/p&gt;
&lt;/blockquote&gt;</content:encoded><h:img src="/_astro/php.DoApYH8-.png"/><enclosure url="/_astro/php.DoApYH8-.png"/></item><item><title>我写了个脚本，统计了我自己写了多少行代码（纯图一乐）</title><link>https://hejunjie.life/blog/c02c3d6d</link><guid isPermaLink="true">https://hejunjie.life/blog/c02c3d6d</guid><description>想知道自己这些年到底写了多少行代码？本文介绍如何用 GitHub CLI 和 cloc 工具，一键拉取所有仓库并统计代码行数，操作简单，图一乐也挺有趣。适合所有想看看自己“码力值”的开发者</description><pubDate>Fri, 08 Aug 2025 09:48:49 GMT</pubDate><content:encoded>&lt;h2&gt;为什么要做这件事？&lt;/h2&gt;
&lt;p&gt;老实说，我平时不太在意自己到底写了多少行代码。&lt;/p&gt;
&lt;p&gt;一方面是因为这东西真没啥太大参考价值，想刷行数的话，复制粘贴个几千行都不是事；另一方面也是因为谁都知道：&lt;strong&gt;代码质量和行数没什么关系&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;但有时候，好奇心就是挡不住。&lt;br&gt;
就像你听到别人讲“十万小时定律”的时候，会突然想：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;“哎，那我到底练习了多久？”&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;我写代码已经很多年了，也做了不少项目，大部分都丢在 GitHub 上没怎么管过。突然有天我就想：&lt;br&gt;
&lt;strong&gt;“我这几年到底写了多少行代码？”&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;没什么远大目标，也不是为了攀比，就是想统计一下，纯图一乐。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;发现了一个小工具：&lt;code&gt;cloc&lt;/code&gt;​&lt;/h2&gt;
&lt;p&gt;为了满足这个好奇心，我去搜了一下有没有能统计代码行数的工具，结果发现了一个叫 &lt;a href=&quot;https://github.com/AlDanial/cloc&quot;&gt;&lt;code&gt;cloc&lt;/code&gt;&lt;/a&gt; 的小东西。&lt;/p&gt;
&lt;p&gt;用起来特别方便，直接在命令行跑一下，它就会帮你统计：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;每种语言用了多少文件；&lt;/li&gt;
&lt;li&gt;每种语言写了多少代码行；&lt;/li&gt;
&lt;li&gt;有多少注释行、空白行；&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;而且输出格式非常清爽，长这样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;-------------------------------------------------------------------------------
Language                     files          blank        comment           code
-------------------------------------------------------------------------------
PHP                            213           4521           2349          19432
JavaScript                      75           1833           1102          10231
...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;看着还挺有成就感的（笑）。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;但问题是：我的代码都在 GitHub 上&lt;/h2&gt;
&lt;p&gt;工具虽然好用，但有个问题：&lt;/p&gt;
&lt;p&gt;我的代码都放在 GitHub 上，平时根本没下载到本地，如果我想统计，就得先一个个仓库 clone 下来，太麻烦了。&lt;/p&gt;
&lt;p&gt;所以我干脆就写了个脚本，把整个流程自动化了。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;脚本主要做了三件事：&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;通过 GitHub 官方的 &lt;code&gt;gh&lt;/code&gt; 命令获取我账号下的所有仓库；&lt;/li&gt;
&lt;li&gt;把这些仓库一个个同步到本地（已有就 pull，没有就 clone）；&lt;/li&gt;
&lt;li&gt;然后用 &lt;code&gt;cloc&lt;/code&gt; 统计整个目录的代码行数，并排除掉构建/依赖目录。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;简单说，就是把 GitHub 上的项目都打包带回家，然后用 &lt;code&gt;cloc&lt;/code&gt; 数一数。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;工具准备（支持 macOS / Ubuntu / Windows）&lt;/h2&gt;
&lt;p&gt;这个脚本依赖以下几个命令行工具：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;​&lt;code&gt;gh&lt;/code&gt;：GitHub CLI，用于获取仓库列表；&lt;/li&gt;
&lt;li&gt;​&lt;code&gt;jq&lt;/code&gt;：命令行 JSON 解析工具，用于处理接口返回的数据；&lt;/li&gt;
&lt;li&gt;​&lt;code&gt;cloc&lt;/code&gt;：统计代码行数的小工具；&lt;/li&gt;
&lt;li&gt;​&lt;code&gt;git&lt;/code&gt;：当然少不了，用来 clone 仓库。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;macOS 安装方法（用 Homebrew）：&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;brew install gh jq cloc git
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Ubuntu / Debian 安装方法：&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo apt update
sudo apt install gh jq cloc git
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Windows 安装方法（建议用 WSL）&lt;/h3&gt;
&lt;p&gt;Windows 用户建议安装 &lt;a href=&quot;&quot;&gt;WSL（Windows 子系统）&lt;/a&gt; 并使用 Ubuntu：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;在 PowerShell（管理员）运行：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-powershell&quot;&gt;wsl --install
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;安装完成后打开 Ubuntu；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;安装依赖：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo apt update
sudo apt install gh jq cloc git
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;GitHub CLI 登录一次（非常重要）&lt;/h3&gt;
&lt;p&gt;运行脚本前，请先登录 GitHub CLI：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;gh auth login
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;它会引导你通过浏览器进行一次性授权。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;脚本源码（可以直接用）&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;#!/bin/bash

USERNAME=&quot;zxc7563598&quot;   # 👈 改成你的 GitHub 用户名
WORKDIR=&quot;$HOME/github_repos&quot;

# 创建存放仓库的目录
mkdir -p &quot;$WORKDIR&quot;
cd &quot;$WORKDIR&quot; || exit

echo &quot;📥 正在获取 $USERNAME 的所有仓库...&quot;

# 获取所有仓库的 HTTPS 地址（最多 200 个，可改成 1000）
gh repo list $USERNAME --limit 200 --json nameWithOwner,url \
  | jq -r &apos;.[].url&apos; | while read repo; do
    reponame=$(basename &quot;$repo&quot;)
    if [ -d &quot;$reponame&quot; ]; then
        echo &quot;🔄 更新仓库: $reponame&quot;
        cd &quot;$reponame&quot; &amp;#x26;&amp;#x26; git pull --quiet &amp;#x26;&amp;#x26; cd ..
    else
        echo &quot;⬇️ 克隆仓库: $reponame&quot;
        git clone --depth=1 &quot;$repo&quot; --quiet
    fi
done

echo &quot;&quot;
echo &quot;📊 开始统计代码行数（排除 node_modules、vendor、dist、build、public）...&quot;
echo &quot;&quot;

cloc --exclude-dir=node_modules,vendor,dist,build,public --exclude-lang=JSON .
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;运行结果展示&lt;/h2&gt;
&lt;p&gt;执行脚本后，大概就会看到类似这样的输出结果（我的结果）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;-------------------------------------------------------------------------------
Language                     files          blank        comment           code
-------------------------------------------------------------------------------
PHP                           2334          14079          60356         613174
Vuejs Component                232           2273           1207          29480
JavaScript                     198           2769           2813          16292
CSS                             13           1566            182          13460
YAML                            15           3345              1          12314
HTML                            43            507            262          11776
Markdown                        52           1881             11           4773
Blade                           27            386             52           3942
Go                               4             80             65            887
WXSS                            10            118             19            753
Bourne Shell                     7            106            104            415
SVG                            350              0              0            415
WXML                             9              7             16            295
Protocol Buffers                 1              2              0            187
C                                1              6              0            101
Python                           1             32             29             91
XML                              1              0              2             29
INI                              2              4              0             20
Dockerfile                       1              1              0              5
DOS Batch                        1              0              0              3
CSV                              1              0              0              2
-------------------------------------------------------------------------------
SUM:                          3303          27162          65119         708414
-------------------------------------------------------------------------------
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;当然，如果你并不像我一样需要从 GitHub 上同步仓库，那你大可不需要使用我的脚本，只需要在你想要统计的目录下执行 &lt;code&gt;cloc .&lt;/code&gt; 就好了&lt;/p&gt;
&lt;p&gt;是不是还挺有意思的？&lt;br&gt;
虽然知道这个数字没啥特别实质意义，但看着这些年自己写的几万行代码，有点像翻相册的感觉。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;总结一下&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;​&lt;code&gt;cloc&lt;/code&gt; 是一个小巧但非常实用的代码行数统计工具；&lt;/li&gt;
&lt;li&gt;如果你的项目都放在 GitHub 上，可以配合 &lt;code&gt;gh&lt;/code&gt; 做自动化统计；&lt;/li&gt;
&lt;li&gt;虽然代码行数并不重要，但偶尔看看还是挺开心的；&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;就当是一种和过去的自己打个招呼吧 ——&lt;br&gt;
“嘿，我还在写代码，而且已经写了好多年了。”&lt;/p&gt;
&lt;/blockquote&gt;</content:encoded><h:img src="/_astro/github.CfLLElkp.jpg"/><enclosure url="/_astro/github.CfLLElkp.jpg"/></item><item><title>构建一个简洁优雅的 PHP 参数验证器 —— php-schema-validator</title><link>https://hejunjie.life/blog/a45e4e99</link><guid isPermaLink="true">https://hejunjie.life/blog/a45e4e99</guid><description>一个轻量、可扩展的 PHP 参数验证器，支持规则数组定义和自定义扩展，适用于非框架项目的数据校验场景</description><pubDate>Thu, 07 Aug 2025 07:36:11 GMT</pubDate><content:encoded>&lt;p&gt;在日常开发中，参数校验是绕不过的一道坎。我们常常需要确保用户传入的数据符合预期格式，比如必填字段、数据类型、最大长度、邮箱格式等等。虽然许多 PHP 框架都内置了验证器，但在开发轻量服务、非框架项目，或需要在业务中后端进行结构化数据校验时，我总觉得现有方案不够灵活、冗余较多。&lt;/p&gt;
&lt;p&gt;于是，我动手写了一个开箱即用、易扩展、轻量级的参数验证器：&lt;strong&gt;&lt;a href=&quot;https://github.com/zxc7563598/php-schema-validator&quot;&gt;php-schema-validator&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;为什么要造这个轮子？&lt;/h2&gt;
&lt;p&gt;虽然 PHP 社区有很多验证类库（如 Laravel 的 Validator、Respect\Validation、Opis JSON Schema 等），但它们往往：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;要么依赖框架，不方便独立使用；&lt;/li&gt;
&lt;li&gt;要么语法冗长，无法用简洁的规则表达复杂校验；&lt;/li&gt;
&lt;li&gt;要么扩展性差，自定义规则成本高。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;我想要一个东西，&lt;strong&gt;既能用类似 JSON Schema 的方式表达规则，又能非常容易地扩展，同时尽量保持核心代码简洁明了。&lt;/strong&gt;&lt;br&gt;
于是这个项目诞生了。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;核心设计理念&lt;/h2&gt;
&lt;p&gt;这个库的核心目标可以总结为三句话：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;规则驱动&lt;/strong&gt; —— 用数组描述你的字段规则，声明式更直观；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;可扩展&lt;/strong&gt; —— 每一个验证规则都是独立的类，插件化设计；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;无依赖&lt;/strong&gt; —— 不依赖框架，适用于任意 PHP 项目。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;安装方式很简单：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;composer require hejunjie/schema-validator
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;一个简单示例：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;use Hejunjie\SchemaValidator\Validator;
use Hejunjie\SchemaValidator\Exceptions\ValidationException;

$data = [
    &apos;name&apos;   =&gt; &apos;张三&apos;,
    &apos;age&apos;    =&gt; 28,
    &apos;email&apos;  =&gt; &apos;invalid-email&apos;,
];

// 自定义扩展，返回 true 则规则通过，否则均视为不通过
Validator::extend(&apos;is_zh&apos;, function ($field, $value, $params = null) {
    if (preg_match(&apos;/^[\x{4e00}-\x{9fa5}]+$/u&apos;, $value)) {
        return true;
    }
});

try {
    Validator::validate($data, [
        &apos;name&apos;  =&gt; [&apos;is_zh&apos;, &apos;string&apos;, &apos;minLength:2&apos;],
        &apos;age&apos;   =&gt; [&apos;integer&apos;, &apos;between:18,60&apos;],
        &apos;email&apos; =&gt; [&apos;required&apos;, &apos;email&apos;],
    ]);
    echo &quot;验证通过 ✅&quot;;
} catch (ValidationException $e) {
    echo &quot;验证失败 ❌&quot; . PHP_EOL;
    print_r($e-&gt;getErrors());  // 友好打印错误信息
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;输出类似：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;验证失败 ❌
Array
(
    [email] =&gt; Array
        (
            [0] =&gt; email
        )

)
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;支持的验证规则&lt;/h2&gt;
&lt;p&gt;目前内置了这些基础规则（并持续扩展中）：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;类型类&lt;/strong&gt;：支持 &lt;code&gt;string&lt;/code&gt; / &lt;code&gt;integer&lt;/code&gt; / &lt;code&gt;boolean&lt;/code&gt; / &lt;code&gt;array&lt;/code&gt; / &lt;code&gt;object&lt;/code&gt; / &lt;code&gt;float&lt;/code&gt; / &lt;code&gt;numeric&lt;/code&gt;​&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;比较类&lt;/strong&gt;：支持 &lt;code&gt;min&lt;/code&gt; / &lt;code&gt;max&lt;/code&gt; / &lt;code&gt;between&lt;/code&gt; / &lt;code&gt;length&lt;/code&gt; / &lt;code&gt;min_length&lt;/code&gt; / &lt;code&gt;max_length&lt;/code&gt; / &lt;code&gt;gt&lt;/code&gt; / &lt;code&gt;lt&lt;/code&gt; / &lt;code&gt;gte&lt;/code&gt; / &lt;code&gt;lte&lt;/code&gt;​&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;格式类&lt;/strong&gt;：支持 &lt;code&gt;email&lt;/code&gt; / &lt;code&gt;mobile&lt;/code&gt; / &lt;code&gt;url&lt;/code&gt; / &lt;code&gt;ip&lt;/code&gt; / &lt;code&gt;json&lt;/code&gt; / &lt;code&gt;alpha&lt;/code&gt; / &lt;code&gt;alpha_num&lt;/code&gt; / &lt;code&gt;alpha_dash&lt;/code&gt;​&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;布尔类&lt;/strong&gt;：支持 &lt;code&gt;required&lt;/code&gt; / &lt;code&gt;accepted&lt;/code&gt; / &lt;code&gt;declined&lt;/code&gt;​&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;自定义类&lt;/strong&gt;：支持 &lt;code&gt;starts_with&lt;/code&gt; / &lt;code&gt;ends_with&lt;/code&gt; / &lt;code&gt;contains&lt;/code&gt;​&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;如何扩展一个自定义规则？&lt;/h2&gt;
&lt;p&gt;比如我想加一个 &lt;code&gt;is_zh&lt;/code&gt;（只允许输入中文）规则：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;use Hejunjie\SchemaValidator\Validator;

// 在调用 Validator::validate 之前
Validator::extend(&apos;is_zh&apos;, function ($field, $value, $params = null) {
    if (preg_match(&apos;/^[\x{4e00}-\x{9fa5}]+$/u&apos;, $value)) {
        return true;
    }
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后就可以直接使用：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;use Hejunjie\SchemaValidator\Validator;
use Hejunjie\SchemaValidator\Exceptions\ValidationException;

try {
    Validator::validate($data, [
        &apos;name&apos;  =&gt; [&apos;is_zh&apos;],
    ]);
	// 验证成功
} catch (ValidationException $e) {
	// 验证失败
}
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;如果有常用自定义规则，建议在代码中封装 &lt;code&gt;Validator::validate&lt;/code&gt; 方法，在调用 &lt;code&gt;validate&lt;/code&gt; 之前通过 &lt;code&gt;extend&lt;/code&gt; 方法注册自定义规则&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;在常驻内存的项目中，建议在项目运行初始化时全局注册，以减小性能开销&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h2&gt;项目地址 &amp;#x26; 使用方式&lt;/h2&gt;
&lt;p&gt;GitHub 仓库地址：&lt;br&gt;
🔗 &lt;a href=&quot;https://github.com/zxc7563598/php-schema-validator&quot;&gt;https://github.com/zxc7563598/php-schema-validator&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;欢迎 Star、提 Issue、提 PR！&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;下一步计划&lt;/h2&gt;
&lt;p&gt;我还在考虑以下几点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;错误信息多语言支持&lt;/li&gt;
&lt;li&gt;Laravel / Webman / Hyperf 插件适配&lt;/li&gt;
&lt;li&gt;添加更多内置规则（银行卡号、身份证号、UUID 等）&lt;/li&gt;
&lt;li&gt;将 schema 校验支持 YAML / JSON 文件描述&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;最后&lt;/h2&gt;
&lt;p&gt;这是一个“造轮子”项目，但也是一个“实践设计”的项目。如果你也曾因为参数验证的重复劳动而烦恼，不妨来试试这个轻量、可扩展的验证器。&lt;/p&gt;
&lt;p&gt;如果这个库对你有帮助，欢迎 Star、提建议或一起共建 🚀&lt;/p&gt;</content:encoded><h:img src="/_astro/php.DoApYH8-.png"/><enclosure url="/_astro/php.DoApYH8-.png"/></item><item><title>如何优雅地处理多种电商优惠规则？我用 PHP 封装了一个 Promotion Engine</title><link>https://hejunjie.life/blog/bfc6fc</link><guid isPermaLink="true">https://hejunjie.life/blog/bfc6fc</guid><description>一个用 PHP 编写的电商优惠计算库，支持满减、打折、VIP 优惠等多种规则，并提供独立、折上折、锁定三种计算模式，让促销逻辑更清晰可控</description><pubDate>Fri, 01 Aug 2025 05:49:04 GMT</pubDate><content:encoded>&lt;p&gt;做电商项目时，经常要处理各种各样的优惠活动：满减、打折、VIP 专属优惠、第二件特价、阶梯优惠……&lt;br&gt;
这些单独实现起来都不复杂，但当你把它们放在一起，就变得混乱起来了。&lt;/p&gt;
&lt;p&gt;我自己在工作里写过不少类似的逻辑，每次做法差不多：&lt;code&gt;if/else&lt;/code&gt;、&lt;code&gt;switch&lt;/code&gt;、各种判断混在一起，过几个月回头看代码，根本不想维护。&lt;br&gt;
于是我干脆写了一个小库，封装了常见的优惠计算逻辑，让这件事更清晰，也能随时在别的项目里用——&lt;strong&gt;&lt;a href=&quot;https://github.com/zxc7563598/php-promotion-engine&quot;&gt;php-promotion-engine&lt;/a&gt;&lt;/strong&gt; 就是这样来的。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;为什么要做这个东西？&lt;/h2&gt;
&lt;p&gt;一个简单的例子：&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;购物车里有满 200 减 50 的活动&lt;/li&gt;
&lt;li&gt;VIP 用户可以再打 9 折&lt;/li&gt;
&lt;li&gt;某些商品买三件还能再减 20&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;这三个优惠叠在一起，怎么算？&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;是每条规则单独算优惠，最后加在一起？&lt;/li&gt;
&lt;li&gt;还是「满减后再打折」的折上折？&lt;/li&gt;
&lt;li&gt;或者一件商品只能享受其中一种优惠？&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;业务里经常会遇到这些问题，而我不想再每个项目都重新造轮子，所以就抽了一个核心逻辑出来，做成 Composer 包。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;我把优惠计算分成了三种模式&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;1. 独立模式（independent）&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;每条优惠规则都&lt;strong&gt;基于商品原价&lt;/strong&gt;独立计算优惠金额，最后把这些金额加起来。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;特点：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;所有优惠平行计算，彼此之间没有影响&lt;/li&gt;
&lt;li&gt;优惠金额往往会比较大（因为每条规则都按原价算）&lt;/li&gt;
&lt;li&gt;运营活动彼此独立时，通常用这种模式&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;例子&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;购物车 300 元，满 200 减 50，VIP 9 折&lt;/li&gt;
&lt;li&gt;「满减」算 50 元优惠&lt;/li&gt;
&lt;li&gt;「VIP」算 30 元优惠（300元 - 300 元 × 0.9）&lt;/li&gt;
&lt;li&gt;最后优惠金额是 80 元，结果是 &lt;strong&gt;220 元&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;2. 折上折模式（sequential）&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这里的优惠是「顺序计算」的，每条规则会根据&lt;strong&gt;上一条规则之后的价格&lt;/strong&gt;来继续打折或满减。&lt;/p&gt;
&lt;p&gt;很多电商活动是顺序叠加的，比如「满减后再打折」，这就是折上折模式的用武之地。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;特点：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;优惠金额会按比例分摊给参与优惠的商品，并实时更新商品价格&lt;/strong&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;下一条规则拿到的是&lt;strong&gt;更新后的价格&lt;/strong&gt;，真正意义上的“折上折”&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;可能出现这样一种情况：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;原价满足满减 → 满减后价格降低 → 后面的优惠金额也变少&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;也可能出现：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;某个商品满减后价格降低，&lt;strong&gt;下一条满减规则再也不满足条件&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;例子&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;购物车 300 元，满 200 减 50，VIP 9 折&lt;/li&gt;
&lt;li&gt;「满减」算 50 元优惠&lt;/li&gt;
&lt;li&gt;「VIP」算 25 元优惠（250 元 - 250 元 × 0.9）&lt;/li&gt;
&lt;li&gt;最终优惠金额是 &lt;strong&gt;75 元&lt;/strong&gt;（比独立模式的 80 元少）&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;3. 锁定模式（lock）&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这个模式更“严格”——&lt;strong&gt;每件商品最多只能享受一条优惠规则&lt;/strong&gt;。&lt;br&gt;
一旦被某个优惠“锁定”，这件商品就不再参与其他规则的计算。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;特点：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;适用于「只能享受一次优惠」的场景（比如秒杀、专属券）&lt;/li&gt;
&lt;li&gt;优惠不会叠加，运营逻辑更容易控制&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;例子&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A 商品被秒杀价锁定，B 商品参与满减&lt;/li&gt;
&lt;li&gt;即使后面有 VIP 折扣，A 商品也不会再打折&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;p&gt;这样拆分之后，&lt;strong&gt;电商里的几乎所有优惠场景都能归到这三类模式之一&lt;/strong&gt;，只需要在引擎里 &lt;code&gt;setMode()&lt;/code&gt; 一下，就能决定计算方式。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;怎么用？&lt;/h2&gt;
&lt;p&gt;安装方式很简单：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;composer require hejunjie/promotion-engine
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后可以直接写：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;use Hejunjie\PromotionEngine\PromotionEngine;
use Hejunjie\PromotionEngine\Rules\FullReductionRule;
use Hejunjie\PromotionEngine\Rules\VipDiscountRule;
use Hejunjie\PromotionEngine\Models\Cart;
use Hejunjie\PromotionEngine\Models\User;

// 创建一个购物车模型，实际场景中使用需要计算商品的名称/价格/购买数量执行即可
// 可以通过第四个参数来设置标签，执行规则时可以设置需要执行的标签，标签支持设置多个
$cart = new Cart();
$cart-&gt;addItem(&apos;T恤&apos;, 120, 1, [&apos;tag&apos;]);
$cart-&gt;addItem(&apos;牛仔裤&apos;, 150, 1, [&apos;tag&apos;,&apos;promo&apos;]);

// 创建一个用户模型，实际场景中仅是用来区分用户是否可以享受关于VIP折扣方面的规则
// 如果没有设置VIP折扣方面的规则，则不会影响任何数据
$user = new User(vip: true);

$engine = new PromotionEngine();
$engine-&gt;setMode(&apos;sequential&apos;); // 选择折上折模式
$engine-&gt;addRule(new Rules\FullReductionRule(100, 30, [&apos;promo&apos;], 1)); // 满 200 减 50, 仅适用于有 promo 标签的商品, 执行顺序 1（数字越小越先执行）
$engine-&gt;addRule(new Rules\VipDiscountRule(0.9, [&apos;tag&apos;], 2));      // VIP 9 折, 仅适用于有 tag 标签的商品, 执行顺序 2（数字越小越先执行）

$result = $engine-&gt;calculate($cart, $user);

print_r(json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;你会得到类似这样的结果：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
    &quot;original&quot;: 270,
    &quot;discount&quot;: 54,
    &quot;final&quot;: 216,
    &quot;details&quot;: [
        &quot;指定商品满100减30 (-¥30)&quot;,
        &quot;VIP 0.9 折 (-¥24)&quot;
    ],
    &quot;items&quot;: [
        {
            &quot;name&quot;: &quot;T恤&quot;,
            &quot;price&quot;: 108,
            &quot;qty&quot;: 1,
            &quot;tags&quot;: [
                &quot;tag&quot;
            ],
            &quot;original_price&quot;: 120,
            &quot;locked&quot;: false
        },
        {
            &quot;name&quot;: &quot;牛仔裤&quot;,
            &quot;price&quot;: 108,
            &quot;qty&quot;: 1,
            &quot;tags&quot;: [
                &quot;tag&quot;,
                &quot;promo&quot;
            ],
            &quot;original_price&quot;: 150,
            &quot;locked&quot;: false
        }
    ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;这个库能带来什么？&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;代码更干净&lt;/strong&gt;：不用每次都写一堆 &lt;code&gt;if/else&lt;/code&gt;，逻辑集中在「规则类」里。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;模式可切换&lt;/strong&gt;：想独立算就 &lt;code&gt;independent&lt;/code&gt;，想折上折就 &lt;code&gt;sequential&lt;/code&gt;，换个模式就行。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;扩展方便&lt;/strong&gt;：有新活动？直接加个规则类，比如「第 N 件打折」「阶梯满减」，不用动核心逻辑。&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;最后&lt;/h2&gt;
&lt;p&gt;这个库不是大而全的框架，就是一个&lt;strong&gt;我在工作中常用到的小工具&lt;/strong&gt;，我把它整理出来放到 GitHub 上，希望以后别的项目也能用得上，也欢迎你们试试看。&lt;/p&gt;
&lt;p&gt;GitHub 地址在这里：&lt;a href=&quot;https://github.com/zxc7563598/php-promotion-engine&quot;&gt;https://github.com/zxc7563598/php-promotion-engine&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;如果你有建议、发现了 bug，或者有好玩的规则想加进来，欢迎 PR。&lt;/p&gt;</content:encoded><h:img src="/_astro/php.DoApYH8-.png"/><enclosure url="/_astro/php.DoApYH8-.png"/></item><item><title>收费跑路的私服玩腻了？2025最新自建DNF单机教程，重回60版本阿拉德！</title><link>https://hejunjie.life/blog/f1f8e9a1</link><guid isPermaLink="true">https://hejunjie.life/blog/f1f8e9a1</guid><description>这篇文章带你重温经典，回到曾经的 DNF 60 版本。从个人经验出发，详细介绍了如何搭建一台 自有单机版 DNF 服务端，避免了私服的坑爹问题，拥有完全自主控制权。完整教程，适合想体验旧版 DNF 的朋友</description><pubDate>Fri, 25 Jul 2025 02:15:56 GMT</pubDate><content:encoded>&lt;p&gt;前段时间突然想回忆下小时候玩 DNF 的感觉，牛头巨兽、僵尸王、光之城主、悬空城。就连技能都要数着技能点算这个点几级那个点不点。&lt;/p&gt;
&lt;p&gt;现在的 DNF 说实话，版本太快了、剧情感觉已经从当年的乡村武侠进步到银河战争了，也不是说这样不好，但是总感觉当年的味道已经完全找不到了&lt;/p&gt;
&lt;p&gt;想着要不玩个私服吧，结果一圈找下来——不是太卡就是太坑，不是要冲钱开会员就是干脆跑路关服。关键是我不是真的图啥竞技氛围，我就想一个人刷刷图，体验一下当年那个60版本的感觉。&lt;/p&gt;
&lt;p&gt;后来一咬牙：干脆自己搞一个单机版得了！&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;为什么要自己搭一个？&lt;/h2&gt;
&lt;p&gt;说白了就一句话：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;不想被人掐着玩，我想啥时候玩就啥时候玩。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;而且自己搭在自己机器上，不用担心服务器关服，不用看别人脸色，也不用氪金，数据也不会莫名其妙丢了。最重要的是，自己能控制一切，有种自己拥有整个阿拉德的感觉。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;那这些私服都是从哪来的？&lt;/h2&gt;
&lt;p&gt;这事其实挺有意思的。&lt;/p&gt;
&lt;p&gt;当年台服 DNF 关服后，服务器端的代码不知道怎么就流出来了，后来就被各路大神研究魔改，从那之后才慢慢有了各种私服、单机端。你现在在网上能找到的服务端，基本都是在这堆源码基础上改出来的。&lt;/p&gt;
&lt;p&gt;不过不管怎么改，底层运行环境其实差不多，&lt;strong&gt;你需要一台可以跑32位程序的 CentOS 7 系统&lt;/strong&gt;，不然不是装不上就是运行报错。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;可以在自己的电脑上跑虚拟机，也可以从类似「阿里云」一类的云服务商购买服务器，一般我会更愿意开一台云服务器，因为服务端跑在自己的电脑上也是有性能消耗的，并且在自己的电脑上跑就绑死自己的电脑了，放在云服务器上，客户端放U盘，不管在什么地方，拿过一台电脑都不耽误我继续玩&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h2&gt;搭建之前，先说点背景&lt;/h2&gt;
&lt;p&gt;折腾 DNF 单机其实就两部分：&lt;strong&gt;服务端&lt;/strong&gt; 和 &lt;strong&gt;客户端&lt;/strong&gt;。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;服务端&lt;/strong&gt; 👉 就是“服务器核心”，管副本逻辑、爆装掉落等等。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;客户端&lt;/strong&gt; 👉 你平时打开玩的那个游戏程序。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;现在网上资源很多，&lt;strong&gt;像千山、夜白这些作者都提供了现成的服务端和配套客户端&lt;/strong&gt;，基本上在 &lt;strong&gt;DNF 台服吧&lt;/strong&gt; 就能找到，搜一搜「DNF 千山」这种关键词，资料一大把。&lt;br&gt;
（&lt;strong&gt;资源这里就不贴了&lt;/strong&gt;，大家自己搜会更灵活）&lt;/p&gt;
&lt;p&gt;这些资源就是最基础的一套“&lt;strong&gt;底板&lt;/strong&gt;”：&lt;br&gt;
有了它，游戏能跑；想玩什么版本，就在这个底板上动手脚。&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;PVF 文件：决定游戏“长什么样”&lt;/h3&gt;
&lt;p&gt;在 DNF 的圈子里，你会经常听到“&lt;strong&gt;PVF&lt;/strong&gt;”这个词。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;PVF 就是游戏的版本文件&lt;/strong&gt;——它定义了副本、职业、装备等各种内容。&lt;/li&gt;
&lt;li&gt;你想体验 60 版本，就换 60 版本的 PVF；想试试后面出的职业，换个新 PVF 就能跑出来。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;通常，&lt;strong&gt;换 PVF 还会带上：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;NPK 文件&lt;/strong&gt;：不同版本的图片、美术资源（怪物、装备、UI…）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;DLL 文件&lt;/strong&gt;：控制一些游戏逻辑，比如最高等级上限&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以简单说，&lt;strong&gt;想换游戏内容&lt;/strong&gt;  &lt;strong&gt;=&lt;/strong&gt;  &lt;strong&gt;换 PVF + 对应资源&lt;/strong&gt;。&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;df_game_r 文件：服务端的心脏&lt;/h3&gt;
&lt;p&gt;另一个关键文件就是 &lt;strong&gt;​&lt;code&gt;df_game_r&lt;/code&gt;​&lt;/strong&gt;​。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;它是 &lt;strong&gt;DNF 服务端的核心进程文件&lt;/strong&gt;，&lt;strong&gt;游戏所有的逻辑都靠它跑&lt;/strong&gt;；&lt;/li&gt;
&lt;li&gt;本质上它也决定了游戏版本和一些内容，客户端连上的就是它。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;重点是：&lt;strong&gt;​&lt;/strong&gt;​&lt;code&gt;df_game_r&lt;/code&gt;​&lt;/strong&gt;​ &lt;strong&gt;是一个 32 位程序。&lt;/strong&gt;&lt;br&gt;
这意味着环境要求比较苛刻——必须装齐一堆  &lt;strong&gt;.i686 库&lt;/strong&gt;、搞对 &lt;code&gt;/lib&lt;/code&gt;​ 里的链接，不然启动就会直接报错。&lt;/p&gt;
&lt;p&gt;现在很多人搭建不成功，其实不是服务端有问题，而是 &lt;strong&gt;环境翻车&lt;/strong&gt;：缺库、版本不对、链接没配好。&lt;br&gt;
所以我建议——&lt;strong&gt;保险起见，搭建前先把所有库更新一遍&lt;/strong&gt;，少踩坑。&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;建议用 CentOS 7.9（64位）&lt;/h3&gt;
&lt;p&gt;为什么推荐这个版本？因为：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;它比较稳定&lt;/li&gt;
&lt;li&gt;各种库版本兼容性最好&lt;/li&gt;
&lt;li&gt;是 CentOS 7 的最后一个官方版本，不容易出幺蛾子&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;记住一句话：CentOS 7.9 + 支持32位库，能跑基本就稳了。&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;那我是怎么装的？&lt;/h2&gt;
&lt;p&gt;不管你是用的什么版本去部署的服务端、有一些版本没问题可以直接跑起来的那自然是最好的，直接畅玩，但是大部分人可能并不会有这种好运气，因此如果出现了问题，我会建议把应该装的库都重新处理一下&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;以下是在所谓的服务端部署完成，服务器重启完成后再去执行的&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;这么做是因为如果提前装好扩展，有些一键包会清理掉已经装好的扩展，导致出现异常&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;准备工作先来一套：&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo curl -o /etc/yum.repos.d/CentOS-Base.repo https://mirrors.aliyun.com/repo/Centos-7.repo
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;换成阿里云的源（有些键包会清理掉yum源，导致下载扩展失败）&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo yum clean all
sudo yum makecache
sudo yum update -y
sudo yum install -y epel-release
sudo yum groupinstall -y &quot;Development Tools&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;清空缓存、更新现有库&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;然后装一堆基础库（64位+32位一块上）：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo yum install -y \
  glibc glibc-devel glibc.i686 glibc-devel.i686 \
  libstdc++ libstdc++-devel libstdc++.i686 libstdc++-devel.i686 \
  zlib zlib-devel zlib.i686 zlib-devel.i686 \
  ncurses ncurses-devel ncurses.i686 ncurses-devel.i686 \
  freetype freetype.i686 freetype-devel freetype-devel.i686 \
  libX11 libX11.i686 libX11-devel libX11-devel.i686 \
  libXext libXext.i686 libXext-devel libXext-devel.i686 \
  libXrender libXrender.i686 libXrender-devel libXrender-devel.i686 \
  openssl openssl-devel \
  readline readline-devel \
  pcre pcre-devel
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3&gt;关键的软链接操作（不然很多老程序找不到东西）&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ln -sf /usr/lib/ld-2.17.so /lib/ld-linux.so.2
sudo ln -sf /usr/lib64/libstdc++.so.6 /usr/lib/libstdc++.so.6
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;再把这俩路径加进 &lt;code&gt;ld.so&lt;/code&gt;​ 里：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;echo &quot;/lib&quot; | sudo tee /etc/ld.so.conf.d/lib.conf
echo &quot;/lib64&quot; | sudo tee /etc/ld.so.conf.d/lib64.conf
sudo ldconfig
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3&gt;设置下环境变量（部分脚本会依赖）&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;export LD_LIBRARY_PATH=/lib:/usr/lib:/lib64:/usr/lib64
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3&gt;检查下装得正不正常&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;rpm -qa | grep -E &apos;glibc|libstdc++|zlib|freetype|ncurses|libX11|libXext|libXrender&apos; | sort
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ls -l /lib/ld-linux.so.2
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;应该输出：/lib/ld-linux.so.2 -&gt; /usr/lib/ld-2.17.so&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;file /usr/lib/libstdc++.so.6 || file /usr/lib64/libstdc++.so.6
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;应该输出：/usr/lib/libstdc++.so.6: symbolic link to `libstdc++.so.6.0.19&apos;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ldd --version
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;应该输出：ldd (GNU libc) 2.17
Copyright (C) 2012 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
由 Roland McGrath 和 Ulrich Drepper 编写。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;strong&gt;接下来就可以去进行不同的版本需要你去做的事情了&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;启动前最后一步：跑起来之前，别忘了这几件小事&lt;/h2&gt;
&lt;p&gt;环境、库都折腾好了，&lt;strong&gt;真正启动服务之前&lt;/strong&gt;，还得在  &lt;strong&gt;​&lt;code&gt;/home/neople/game&lt;/code&gt;​&lt;/strong&gt;​ 目录（大部分服务端都是这里，不在这里的服务端，安装时一般会特别说明）先做三件事：&lt;/p&gt;
&lt;p&gt;1️⃣ &lt;strong&gt;加入或替换&lt;/strong&gt; &lt;strong&gt;​&lt;code&gt;df_game_r&lt;/code&gt;​&lt;/strong&gt;​&lt;br&gt;
👉 这个文件决定了游戏跑的是哪个版本，几乎所有一键包都会自带或者提供下载链接。换了它，就等于换了“游戏内核”。&lt;/p&gt;
&lt;p&gt;2️⃣ &lt;strong&gt;加入或替换&lt;/strong&gt; &lt;strong&gt;​&lt;code&gt;Script.pvf&lt;/code&gt;​&lt;/strong&gt;​&lt;br&gt;
👉 这是游戏内容的大脑，职业数值、副本逻辑全靠它。有些魔改大佬就是改 &lt;code&gt;PVF&lt;/code&gt;​ 来加东西（比如新职业、变态装备），网上的资源也一大把。&lt;/p&gt;
&lt;p&gt;3️⃣ &lt;strong&gt;加入或替换&lt;/strong&gt; &lt;strong&gt;​&lt;code&gt;publickey.pem&lt;/code&gt;​&lt;/strong&gt;​&lt;br&gt;
👉 这是客户端和服务端互相“认身份”的凭证。客户端目录里都有，只要服务端和客户端用的 &lt;code&gt;publickey.pem&lt;/code&gt;​ 一致，客户端才能正常连上服务端。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;🔧 &lt;strong&gt;这三件事做完，再去执行对应一键包的启动命令&lt;/strong&gt;（一般是 &lt;code&gt;/root&lt;/code&gt;​ 目录下的 &lt;code&gt;./run&lt;/code&gt;​）&lt;/p&gt;
&lt;p&gt;最后等个几分钟十几分钟的，直到看到这样的信息&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;GeoIP Allow Country Code : CN
GeoIP Allow Country Code : HK
GeoIP Allow Country Code : KR
GeoIP Allow Country Code : MO
GeoIP Allow Country Code : TW
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;就能顺利开服了，也就是大家常说的「跑五国」&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;客户端这边也别忘了改 IP&lt;/h2&gt;
&lt;p&gt;客户端目录里通常会有 &lt;strong&gt;​&lt;code&gt;game.ini&lt;/code&gt;​&lt;/strong&gt;​ 和 &lt;strong&gt;​&lt;code&gt;dnf.toml&lt;/code&gt;​&lt;/strong&gt;​ 这两个文件，都能用记事本打开。&lt;br&gt;
把里面的服务器 IP 改成你自己服务器的地址，保存。&lt;/p&gt;
&lt;p&gt;这样客户端才能连上你自己的服务端。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;最后&lt;/h2&gt;
&lt;p&gt;写这个也不是想教大家怎么玩服务器，纯粹就是分享一下我的折腾经历，毕竟我查了好久中文资料，大多都讲得不明不白。要不是踩了几个坑，我可能也不想自己写了。&lt;/p&gt;
&lt;p&gt;如果你也跟我一样，想再体验一次当年的 DNF，那真不妨动手试试。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;私服再多，不如自己动手。数据、节奏、体验，全都在你掌控中。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;如果你觉得有用，点个赞或者收藏下吧。&lt;br&gt;
也欢迎来评论区交流搭建时遇到的问题，我能帮的都会帮 👍&lt;/p&gt;</content:encoded><h:img src="/_astro/dnf.O8L3rJJE.png"/><enclosure url="/_astro/dnf.O8L3rJJE.png"/></item><item><title>用 GitHub Issues 做任务管理和任务 List，简单好用！</title><link>https://hejunjie.life/blog/98b8dd2</link><guid isPermaLink="true">https://hejunjie.life/blog/98b8dd2</guid><description>分享如何用 GitHub Issues 做任务管理和任务 List，告别零散文档和堆积聊天记录，通过结构化 Issue 模板让每日任务安排更高效，更自然</description><pubDate>Mon, 23 Jun 2025 03:59:37 GMT</pubDate><content:encoded>&lt;p&gt;说实话，我平时也是一个人写代码，每次开完会整理任务最麻烦：&lt;/p&gt;
&lt;p&gt;一堆事项堆在聊天里、文档里，或者散落在邮件里……&lt;/p&gt;
&lt;p&gt;为了理清这些，我通常会做一份 List，标好优先级，再安排到每日的工作里&lt;/p&gt;
&lt;p&gt;虽然这个办法能解决大部分问题&lt;/p&gt;
&lt;p&gt;但说实在的：&lt;/p&gt;
&lt;p&gt;一是&lt;strong&gt;整理 List 本身就需要时间&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;二是每完成一件事，还得&lt;strong&gt;回到 List 去勾掉已完成的任务&lt;/strong&gt;
让人感觉并没有那么顺手、痛快&lt;/p&gt;
&lt;p&gt;于是，我也一直在想：
&lt;strong&gt;有没有更好的办法，更自然地安排和管理任务呢？&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;我是怎么想到用 Issue 模板的&lt;/h2&gt;
&lt;p&gt;其实 GitHub Issues 我们都见过，也都用过。&lt;br&gt;
但默认的 Issues 创建体验其实比较简单，也没什么结构化管理。&lt;/p&gt;
&lt;p&gt;后来看了一些大项目，发现他们都有自己的 &lt;strong&gt;Issue 模板&lt;/strong&gt;，让提交 Issue 的人：&lt;/p&gt;
&lt;p&gt;✅ 按结构化字段提供信息&lt;br&gt;
✅ 自动附加 Labels&lt;br&gt;
✅ 简单一看就明白这个 Issue 是干什么的&lt;/p&gt;
&lt;p&gt;我就想：「那我自己的小仓库，能不能也搞一套？就算是一个人的项目，也能更有秩序。」&lt;br&gt;
结果发现，还真很好用！&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;用途示例：Issue 模板让事务更有结构&lt;/h2&gt;
&lt;p&gt;现在，我是这么做的：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;用 Issue 模板管理自己的任务。&lt;/li&gt;
&lt;li&gt;把 Issue 当成小型看板。&lt;/li&gt;
&lt;li&gt;用 Labels 标记状态（pending、in progress、review、done）。&lt;/li&gt;
&lt;li&gt;完成代码后，在 PR 描述里写 &lt;code&gt;fixes #xxx&lt;/code&gt;​，合并后 Issue 就自动关闭。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这个简单的小改造，让我：&lt;br&gt;
✅ 没有因为漏掉任务而后悔过&lt;br&gt;
✅ 完成和提交之间有记录和参考&lt;br&gt;
✅ 即使过很久，也能快速回顾历史&lt;/p&gt;
&lt;p&gt;更好的是，因为可以分配负责人&lt;code&gt;Assignee&lt;/code&gt;，因此在团队协作中，通过这种方案，可以非常便捷的&lt;strong&gt;将各个需要处理的任务拆分之后分配给不同的成员负责&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;成员可以直接根据 Issue 来工作，完成工作后提交即可，在审计通过后任务则会自动关闭 Issue ，整个流程非常丝滑&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;article/98b8dd2/00001.png&quot; alt=&quot;通过模板创建 Issue 页面&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;article/98b8dd2/00002.png&quot; alt=&quot;Issue 模板 - Bug 反馈&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;article/98b8dd2/00004.png&quot; alt=&quot;Issue 模板 - 需求排期&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;article/98b8dd2/00003.png&quot; alt=&quot;Issue 模板 - 问题/疑问&quot;&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;怎么配置 Issue 模板&lt;/h2&gt;
&lt;p&gt;其实配置 Issue 模板没有什么复杂，就是在 &lt;code&gt;仓库根目录/.github/ISSUE_TEMPLATE&lt;/code&gt; 文件夹中创建 &lt;code&gt;.yml&lt;/code&gt;​ 文件。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;目录不存在可以自行创建&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;对于 &lt;code&gt;.yml&lt;/code&gt; 文件的命名没有要求，GitHub会解析 &lt;code&gt;config.yml&lt;/code&gt; 作为配置文件，其余所有 &lt;code&gt;.yml&lt;/code&gt; 文件均视作 Issue 模板&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;config.yml 配置说明&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;blank_issues_enabled: false
contact_links:
  - name: 📌 功能建议提交说明
    url: https://example.com/docs/issues
    about: 在提交 Issue 之前，请先阅读提交流程说明。
  - name: ❓ 问答社区
    url: https://example.com/discussions
    about: 有疑问？先来社区讨论。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;|字段|用途|
| ------| -------------------------------------|
|&lt;strong&gt;blank_issues_enabled&lt;/strong&gt;|是否允许提交非模板的 Issue|
|&lt;strong&gt;contact_links&lt;/strong&gt;|为提交 Issue 的页面提供额外链接|&lt;/p&gt;
&lt;h3&gt;Issue 模板配置文件简单示例：&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;name: 🐞 Bug 报告
description: 用于提交 bug 报告
title: &quot;[Bug]: &quot;
labels: [&quot;bug&quot;, &quot;pending&quot;]

body:
  - type: input
    id: title
    attributes:
      label: 问题标题
      placeholder: 简单概括问题
    validations:
      required: true
  - type: textarea
    id: details
    attributes:
      label: 问题描述
      description: 请附上堆栈、截图或参考代码片段
    validations:
      required: true
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Issue 模板 YML 可用字段一览&lt;/h3&gt;
&lt;p&gt;|字段|用途|示例|
| ------| -------------------------------------| ------------|
|&lt;strong&gt;name&lt;/strong&gt;|模板显示的名字|​&lt;code&gt;name: 🐞 Bug 报告&lt;/code&gt;​|
|&lt;strong&gt;description&lt;/strong&gt;|模板显示的描述|​&lt;code&gt;description: 用于提交 bug 报告&lt;/code&gt;​|
|&lt;strong&gt;title&lt;/strong&gt;|创建 Issue 时自动填充的标题|​&lt;code&gt;title: &quot;[Bug]: &quot;&lt;/code&gt;​|
|&lt;strong&gt;labels&lt;/strong&gt;|创建 Issue 自动附加的 Label 列表|​&lt;code&gt;labels: [&quot;bug&quot;, &quot;pending&quot;]&lt;/code&gt;​|
|&lt;strong&gt;assignees&lt;/strong&gt;|创建 Issue 自动指定的负责人|​&lt;code&gt;assignees: [&quot;user1&quot;, &quot;user2&quot;]&lt;/code&gt;​|
|&lt;strong&gt;body&lt;/strong&gt;|模板主体区域，里面是字段配置数组|见下文示例|
|&lt;strong&gt;body.type&lt;/strong&gt;|字段类型：&lt;code&gt;input&lt;/code&gt;​、&lt;code&gt;textarea&lt;/code&gt;​、&lt;code&gt;dropdown&lt;/code&gt;​、&lt;code&gt;checkboxes&lt;/code&gt;​、&lt;code&gt;markdown&lt;/code&gt;​|​&lt;code&gt;type: input&lt;/code&gt;​|
|&lt;strong&gt;body.id&lt;/strong&gt;|唯一标识字段 ID，用于引用值|​&lt;code&gt;id: description&lt;/code&gt;​|
|&lt;strong&gt;body.attributes.label&lt;/strong&gt;|在 Issue 创建页面显示的字段标题|​&lt;code&gt;label: 问题描述&lt;/code&gt;​|
|&lt;strong&gt;body.attributes.description&lt;/strong&gt;|在 Issue 创建页面显示的字段说明|​&lt;code&gt;description: 请详细描述...&lt;/code&gt;​|
|&lt;strong&gt;body.attributes.placeholder&lt;/strong&gt;|在 Issue 创建页面显示的默认值或示例|​&lt;code&gt;placeholder: 在这里写错误日志...&lt;/code&gt;​|
|&lt;strong&gt;body.attributes.options&lt;/strong&gt;|可选项列表，仅对&lt;code&gt;dropdown&lt;/code&gt;​、&lt;code&gt;checkboxes&lt;/code&gt;​类型有效|​&lt;code&gt;options: [&quot;选项1&quot;, &quot;选项2&quot;]&lt;/code&gt;​|
|&lt;strong&gt;body.validations.required&lt;/strong&gt;|是否为必填字段 (&lt;code&gt;true&lt;/code&gt;​/&lt;code&gt;false&lt;/code&gt;​)|​&lt;code&gt;required: true&lt;/code&gt;​|&lt;/p&gt;
&lt;h3&gt;可用的字段类型&lt;/h3&gt;
&lt;p&gt;|类型|用途|
| ------| --------------------------------------------------------|
|​&lt;strong&gt;​markdown​&lt;/strong&gt;​|只展示一段 Markdown 内容，不可编辑，适合作为提示、说明|
|​&lt;strong&gt;​input&lt;/strong&gt;​|单行文本字段，适合填写标题、URL、单行参数等|
|​&lt;strong&gt;​textarea&lt;/strong&gt;​|多行文本字段，适合错误日志、堆栈、代码片段|
|​&lt;strong&gt;​dropdown&lt;/strong&gt;​|可选下拉框，适合确定性选项（如操作系统类型、环境类型）|
|​&lt;strong&gt;​checkboxes​&lt;/strong&gt;​|多选框，适合提供多个选项让提交者勾选|&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;我自己的配置示例&lt;/h2&gt;
&lt;p&gt;我也整理了一套自己的 Issue 模板，用于：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;✅ 问题提问&lt;/li&gt;
&lt;li&gt;✅ 合作／雇佣邀请&lt;/li&gt;
&lt;li&gt;✅ bug 报告&lt;/li&gt;
&lt;li&gt;✅ 需求排期&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;👉 我放在自己的个人仓库里，大家感兴趣可以参考：&lt;a href=&quot;https://github.com/zxc7563598/zxc7563598&quot;&gt;点击查看&lt;/a&gt;​&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;简而言之&lt;/h2&gt;
&lt;p&gt;说实话，GitHub Issues 模板对我这种个人开发者，也能带来很大好处：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;更清晰的任务结构。&lt;/li&gt;
&lt;li&gt;完成和提交之间有标准化记录。&lt;/li&gt;
&lt;li&gt;即使过很久，也能快速回顾。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果你也是一个平时单兵作战、但想让自己的工作更有结构的人，建议你也可以尝试配置一套 Issue 模板。&lt;br&gt;
它不会让你额外麻烦，反而让后续一切更简单。&lt;/p&gt;</content:encoded><h:img src="/_astro/github.CfLLElkp.jpg"/><enclosure url="/_astro/github.CfLLElkp.jpg"/></item><item><title>用 WakaTime + GitHub Actions 打造属于你的个性化 GitHub 主页</title><link>https://hejunjie.life/blog/357ee777</link><guid isPermaLink="true">https://hejunjie.life/blog/357ee777</guid><description>手把手教你用 WakaTime 和 GitHub Actions 打造一个自动更新、内容丰富的 GitHub 主页，展示你的编码实力与技术热情</description><pubDate>Mon, 23 Jun 2025 03:59:37 GMT</pubDate><content:encoded>&lt;p&gt;在技术人眼里，GitHub 不只是代码仓库，它也是你的&lt;strong&gt;技术简历、作品展厅&lt;/strong&gt;，甚至是你在互联网上的“第二张名片”。&lt;/p&gt;
&lt;p&gt;而今天，我们就来聊聊：&lt;strong&gt;如何从零开始，打造一个自动更新、内容丰富、有个性、还能展示技术实力的 GitHub 主页。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;先贴一张我的 GitHub 个人页面，你也可以直接打开我的 GitHub 主页查看：&lt;a href=&quot;http://github.com/zxc7563598&quot;&gt;点击查看&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;article/357ee777/readme.png&quot; alt=&quot;zxc7563598&quot;&gt;&lt;/p&gt;
&lt;h2&gt;🏠 从一个特殊的仓库开始：GitHub 主页原理&lt;/h2&gt;
&lt;p&gt;GitHub 提供了一个非常简洁的展示方式：
只要你创建一个和自己用户名相同的仓库，README 就会展示在你个人主页顶部。&lt;/p&gt;
&lt;p&gt;比如我叫 &lt;code&gt;zxc7563598&lt;/code&gt;，那我就新建一个仓库叫：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;zxc7563598/zxc7563598
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个仓库的 &lt;code&gt;README.md&lt;/code&gt;，就是你在 GitHub 首页展示的内容。你可以在里面写个人介绍、技能栈、项目链接，甚至放个代码统计图表。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;技术人自己的「About Me」，从这里开始。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;⌛ 加一点数据味道：使用 WakaTime 统计代码时间&lt;/h2&gt;
&lt;p&gt;如果你希望展示的不只是“看起来厉害”，而是“我真的在持续写代码”，那么推荐你使用 WakaTime ——一个开发者专用的时间追踪工具。&lt;/p&gt;
&lt;p&gt;它支持 VSCode / JetBrains / Vim / 甚至 Eclipse，安装插件、填入 API Key 后，它就能悄悄记录你每天写了哪些语言、用了多长时间。&lt;/p&gt;
&lt;p&gt;你得到的是一份“编程时间日报”，而不是模糊的「我最近在忙项目」。&lt;/p&gt;
&lt;p&gt;为了使用它，你需要完成下面这些事情：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;前往 &lt;a href=&quot;https://wakatime.com&quot;&gt;wakatime.com&lt;/a&gt; 并创建一个帐户。&lt;/li&gt;
&lt;li&gt;从 WakaTime 中的帐户设置中获取 WakaTime API 密钥。&lt;/li&gt;
&lt;li&gt;在您最喜欢的编辑器/IDE 中安装 WakaTime 插件。官网都有非常完善的教程。&lt;/li&gt;
&lt;li&gt;粘贴您的 API 密钥以开始分析。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;🔁 自动更新：用 GitHub Actions 每天写入统计数据&lt;/h2&gt;
&lt;p&gt;有了 WakaTime 数据，还得展示在主页上。&lt;/p&gt;
&lt;p&gt;我们可以借助开源项目 &lt;a href=&quot;https://github.com/anmol098/waka-readme-stats&quot;&gt;athul/waka-readme&lt;/a&gt;，通过 GitHub Actions 每天自动将你最新的 WakaTime 统计写入 README.md。&lt;/p&gt;
&lt;p&gt;具体操作方法如下：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;创建一个 &lt;code&gt;GitHub Token&lt;/code&gt;，并授予 &lt;code&gt;repo&lt;/code&gt; 权限。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;如果你不知道从哪里设置 &lt;code&gt;GitHub Token&lt;/code&gt; 可以直接从这个链接进入并进行设置 &lt;a href=&quot;https://github.com/settings/tokens/new&quot;&gt;点击查看&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;在你的仓库中创建一个名为 &lt;code&gt;.github/workflows/waka.yml&lt;/code&gt; 的文件，并添加以下内容：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yml&quot;&gt; name: Waka Readme

 on:
 schedule:
     - cron: &apos;0 16 * * *&apos;
 workflow_dispatch:
 jobs:
 update-readme:
     name: Update Readme with Metrics
     runs-on: ubuntu-latest
     steps:
     - uses: anmol098/waka-readme-stats@master
         with:
         WAKATIME_API_KEY: ${{ secrets.WAKATIME_API_KEY }}
         GH_TOKEN: ${{ secrets.GH_TOKEN }}
         LOCALE: zh
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;设置 &lt;code&gt;WAKATIME_API_KEY&lt;/code&gt; 和 &lt;code&gt;GH_TOKEN&lt;/code&gt;
在仓库的 &lt;code&gt;Settings&lt;/code&gt; -&gt; &lt;code&gt;Secrets and variables&lt;/code&gt; -&gt; &lt;code&gt;Actions&lt;/code&gt; -&gt; &lt;code&gt;New repository secret&lt;/code&gt; 中添加 &lt;code&gt;WAKATIME_API_KEY&lt;/code&gt; 和 &lt;code&gt;GH_TOKEN&lt;/code&gt; 两个密钥。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;这里的 &lt;code&gt;WAKATIME_API_KEY&lt;/code&gt; 就是你之前获取的 WakaTime API 密钥，&lt;code&gt;GH_TOKEN&lt;/code&gt; 就是你之前创建的 &lt;code&gt;GitHub Token&lt;/code&gt;。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;在 README.md 中你想要添加统计的位置添加以下代码：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt; &amp;#x3C;!--START_SECTION:waka--&gt;
 &amp;#x3C;!--END_SECTION:waka--&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;至此，所有的配置就都已经完成了，GitHub Actions 会在北京时间 00:00 自动执行，并将该段代码替换为 WakaTime 的统计数据&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;blockquote&gt;
&lt;p&gt;你也可以手动执行，在仓库的 Actions 中点击 &lt;code&gt;Waka Readme&lt;/code&gt;，然后点击 &lt;code&gt;Run workflow&lt;/code&gt; 按钮即可立即手动执行&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;🎁 最后：别让主页变成“Hello World”&lt;/h2&gt;
&lt;p&gt;很多开发者注册 GitHub 后就再也没碰过个人主页，但其实这里是你展现专业度与热情的一个绝佳窗口。&lt;/p&gt;
&lt;p&gt;不管你是找工作、做开源、还是纯粹想留下点印记，都值得花一两个小时，搭建一个属于你的主页。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;项目代码会说话，你的主页也一样。&lt;/p&gt;
&lt;/blockquote&gt;</content:encoded><h:img src="/_astro/github.CfLLElkp.jpg"/><enclosure url="/_astro/github.CfLLElkp.jpg"/></item><item><title>私有网络与服务端通信配置记录</title><link>https://hejunjie.life/blog/hqnd9ut2</link><guid isPermaLink="true">https://hejunjie.life/blog/hqnd9ut2</guid><description>本文为作者个人的网络服务配置笔记，仅供自用。内容涉及敏感配置，公开可能引发风险。如确有学习需求，请联系作者获取访问权限</description><pubDate>Fri, 13 Jun 2025 08:52:45 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;背景介绍：&lt;/p&gt;
&lt;p&gt;在国内直接访问境外网站常受限制，原因在于国内对境外流量进行了严格控制。&lt;br&gt;
但有一个特点：&lt;strong&gt;国内到港澳台之间是互通的&lt;/strong&gt;。&lt;br&gt;
也就是说，你可以买一台最便宜的香港服务器作为“中转”，因为：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;国内 -&gt; 香港之间速度极快，延迟极低。&lt;/li&gt;
&lt;li&gt;香港 -&gt; 境外的连接没有封锁。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;比如，阿里云香港机器，&lt;strong&gt;1 元 / 1GB&lt;/strong&gt; 流量，完全够用，性价比极高，且不限速。&lt;br&gt;
用 Shadowsocks 做一层简单转发，配合 Clash 客户端，完全可以跑满带宽。&lt;/p&gt;
&lt;p&gt;简而言之：&lt;br&gt;
用一台香港服务器当“跳板”，国内到香港不限速，香港再帮你访问境外。&lt;/p&gt;
&lt;p&gt;接下来是完整配置步骤。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h2&gt;一、 在服务器上安装 Shadowsocks 服务端&lt;/h2&gt;
&lt;p&gt;推荐使用官方维护的 &lt;strong&gt;shadowsocks-libev&lt;/strong&gt;，因为简单、稳定、性能好。&lt;/p&gt;
&lt;h4&gt;更新软件源&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo apt update
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;安装 Shadowsocks&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo apt install shadowsocks-libev
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;二、配置 Shadowsocks 服务&lt;/h2&gt;
&lt;p&gt;配置文件默认放在：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/etc/shadowsocks-libev/config.json
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;修改配置：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo vim /etc/shadowsocks-libev/config.json
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;修改为：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
    &quot;server&quot;: &quot;0.0.0.0&quot;,
    &quot;mode&quot;: &quot;tcp_and_udp&quot;,
    &quot;server_port&quot;: 8388,
    &quot;local_port&quot;: 1080,
    &quot;password&quot;: &quot;随便起一个密码&quot;,
    &quot;timeout&quot;: 86400,
    &quot;method&quot;: &quot;chacha20-ietf-poly1305&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;关键参数说明：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;​&lt;code&gt;server&lt;/code&gt;​: &lt;code&gt;0.0.0.0&lt;/code&gt;​ 表示监听所有网卡。&lt;/li&gt;
&lt;li&gt;​&lt;code&gt;server_port&lt;/code&gt;​: 修改为你需要的端口。&lt;/li&gt;
&lt;li&gt;​&lt;code&gt;password&lt;/code&gt;​: 修改为自己的密码。&lt;/li&gt;
&lt;li&gt;​&lt;code&gt;method&lt;/code&gt;​: 可选 &lt;code&gt;chacha20-ietf-poly1305&lt;/code&gt;​、&lt;code&gt;aes-256-gcm&lt;/code&gt;​ 等。&lt;/li&gt;
&lt;li&gt;​&lt;code&gt;mode&lt;/code&gt;​: 通常为 &lt;code&gt;tcp_and_udp&lt;/code&gt;​，使 UDP 可用。&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h2&gt;三、 启动和管理&lt;/h2&gt;
&lt;p&gt;启动服务：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo systemctl restart shadowsocks-libev.service
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;检查状态：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo systemctl status shadowsocks-libev.service
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;配置开机自动启动：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo systemctl enable shadowsocks-libev.service
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;四、 检查是否正常&lt;/h2&gt;
&lt;p&gt;在服务器上检查端口是否在监听：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ss -tulpn | grep ss-server
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果正常，会显示 &lt;code&gt;ss-server&lt;/code&gt;​ 在监听配置好的端口。&lt;/p&gt;
&lt;p&gt;在客户端检查连接：&lt;br&gt;
修改 Clash 节点：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;- name: &quot;我的服务器&quot;
  type: ss
  server: 服务器IP
  port: 8388
  cipher: chacha20-ietf-poly1305
  password: &quot;设置好的密码&quot;
  udp: false
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;五、 附加建议&lt;/h2&gt;
&lt;h3&gt;配置防火墙&lt;/h3&gt;
&lt;p&gt;允许相应端口：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ufw allow 8388/tcp
sudo ufw allow 8388/udp
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;检查日志&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;journalctl -u shadowsocks-libev.service -f
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;六、 客户端配置参考链接&lt;/h2&gt;
&lt;p&gt;Windows 客户端：&lt;a href=&quot;https://www.clashforwindows.net&quot;&gt;Clash for Windows&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;iOS 客户端：&lt;a href=&quot;https://apps.apple.com/us/app/shadowrocket/id932747118&quot;&gt;Shadowrocket&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Android 客户端：&lt;a href=&quot;https://clashforandroid.org/clash-for-android-download&quot;&gt;Clash for Android&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Mac 客户端：&lt;a href=&quot;https://clashverge.net&quot;&gt;Clash Verge&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;七、 配置参考&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;Clash 完整配置规则&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre&gt;&lt;code class=&quot;language-yml&quot;&gt;port: 7890
socks-port: 7891
redir-port: 7892
allow-lan: false
mode: Rule
log-level: info
external-controller: 127.0.0.1:9090
secret: &quot;&quot;
cfw-bypass:
  - localhost
  - 127.*
  - 10.*
  - 172.16.*
  - 172.17.*
  - 172.18.*
  - 172.19.*
  - 172.20.*
  - 172.21.*
  - 172.22.*
  - 172.23.*
  - 172.24.*
  - 172.25.*
  - 172.26.*
  - 172.27.*
  - 172.28.*
  - 172.29.*
  - 172.30.*
  - 172.31.*
  - 192.168.*
  - &amp;#x3C;local&gt;
cfw-latency-timeout: 3000
proxies:
  - name: &quot;我的服务器&quot;
    type: ss
    server: 服务器IP
    port: 8388
    cipher: chacha20-ietf-poly1305
    password: &quot;设置好的密码&quot;
    udp: false
proxy-groups:
  - name: CroLAX
    type: select
    proxies:
      - &quot;HongKong-Proxy&quot;
      - &quot;DIRECT&quot;
rules:
  - DOMAIN,hls.itunes.apple.com,CroLAX
  - DOMAIN,itunes.apple.com,CroLAX
  - DOMAIN,itunes.com,CroLAX
  - DOMAIN-SUFFIX,icloud.com,DIRECT
  - DOMAIN-SUFFIX,icloud-content.com,DIRECT
  - DOMAIN-SUFFIX,me.com,DIRECT
  - DOMAIN-SUFFIX,mzstatic.com,DIRECT
  - DOMAIN-SUFFIX,aaplimg.com,DIRECT
  - DOMAIN-SUFFIX,cdn-apple.com,DIRECT
  - DOMAIN-SUFFIX,apple.com,DIRECT
  ## 国内网站
  - DOMAIN-SUFFIX,akadns.net,DIRECT
  - DOMAIN-SUFFIX,akamaized.net,DIRECT
  - DOMAIN-SUFFIX,cn,DIRECT
  - DOMAIN-KEYWORD,-cn,DIRECT
  - DOMAIN-SUFFIX,126.com,DIRECT
  - DOMAIN-SUFFIX,126.net,DIRECT
  - DOMAIN-SUFFIX,127.net,DIRECT
  - DOMAIN-SUFFIX,163.com,DIRECT
  - DOMAIN-SUFFIX,360buyimg.com,DIRECT
  - DOMAIN-SUFFIX,36kr.com,DIRECT
  - DOMAIN-SUFFIX,acfun.tv,DIRECT
  - DOMAIN-SUFFIX,air-matters.com,DIRECT
  - DOMAIN-SUFFIX,aixifan.com,DIRECT
  - DOMAIN-KEYWORD,alicdn,DIRECT
  - DOMAIN-KEYWORD,alipay,DIRECT
  - DOMAIN-KEYWORD,taobao,DIRECT
  - DOMAIN-SUFFIX,amap.com,DIRECT
  - DOMAIN-SUFFIX,autonavi.com,DIRECT
  - DOMAIN-KEYWORD,baidu,DIRECT
  - DOMAIN-SUFFIX,bdimg.com,DIRECT
  - DOMAIN-SUFFIX,bdstatic.com,DIRECT
  - DOMAIN-SUFFIX,bilibili.com,DIRECT
  - DOMAIN-SUFFIX,caiyunapp.com,DIRECT
  - DOMAIN-SUFFIX,clouddn.com,DIRECT
  - DOMAIN-SUFFIX,cnbeta.com,DIRECT
  - DOMAIN-SUFFIX,cnbetacdn.com,DIRECT
  - DOMAIN-SUFFIX,cootekservice.com,DIRECT
  - DOMAIN-SUFFIX,csdn.net,DIRECT
  - DOMAIN-SUFFIX,ctrip.com,DIRECT
  - DOMAIN-SUFFIX,dgtle.com,DIRECT
  - DOMAIN-SUFFIX,dianping.com,DIRECT
  - DOMAIN-SUFFIX,douban.com,DIRECT
  - DOMAIN-SUFFIX,doubanio.com,DIRECT
  - DOMAIN-SUFFIX,duokan.com,DIRECT
  - DOMAIN-SUFFIX,easou.com,DIRECT
  - DOMAIN-SUFFIX,ele.me,DIRECT
  - DOMAIN-SUFFIX,feng.com,DIRECT
  - DOMAIN-SUFFIX,fir.im,DIRECT
  - DOMAIN-SUFFIX,frdic.com,DIRECT
  - DOMAIN-SUFFIX,g-cores.com,DIRECT
  - DOMAIN-SUFFIX,godic.net,DIRECT
  - DOMAIN-SUFFIX,gtimg.com,DIRECT
  - DOMAIN-SUFFIX,hongxiu.com,DIRECT
  - DOMAIN-SUFFIX,hxcdn.net,DIRECT
  - DOMAIN-SUFFIX,iciba.com,DIRECT
  - DOMAIN-SUFFIX,ifeng.com,DIRECT
  - DOMAIN-SUFFIX,ifengimg.com,DIRECT
  - DOMAIN-SUFFIX,ipip.net,DIRECT
  - DOMAIN-SUFFIX,iqiyi.com,DIRECT
  - DOMAIN-SUFFIX,jd.com,DIRECT
  - DOMAIN-SUFFIX,jianshu.com,DIRECT
  - DOMAIN-SUFFIX,knewone.com,DIRECT
  - DOMAIN-SUFFIX,le.com,DIRECT
  - DOMAIN-SUFFIX,lecloud.com,DIRECT
  - DOMAIN-SUFFIX,lemicp.com,DIRECT
  - DOMAIN-SUFFIX,licdn.com,DIRECT
  - DOMAIN-SUFFIX,luoo.net,DIRECT
  - DOMAIN-SUFFIX,meituan.com,DIRECT
  - DOMAIN-SUFFIX,meituan.net,DIRECT
  - DOMAIN-SUFFIX,mi.com,DIRECT
  - DOMAIN-SUFFIX,miaopai.com,DIRECT
  - DOMAIN-SUFFIX,microsoft.com,DIRECT
  - DOMAIN-SUFFIX,microsoftonline.com,DIRECT
  - DOMAIN-SUFFIX,miui.com,DIRECT
  - DOMAIN-SUFFIX,miwifi.com,DIRECT
  - DOMAIN-SUFFIX,mob.com,DIRECT
  - DOMAIN-SUFFIX,netease.com,DIRECT
  - DOMAIN-SUFFIX,office.com,DIRECT
  - DOMAIN-KEYWORD,officecdn,DIRECT
  - DOMAIN-SUFFIX,office365.com,DIRECT
  - DOMAIN-SUFFIX,oschina.net,DIRECT
  - DOMAIN-SUFFIX,ppsimg.com,DIRECT
  - DOMAIN-SUFFIX,pstatp.com,DIRECT
  - DOMAIN-SUFFIX,qcloud.com,DIRECT
  - DOMAIN-SUFFIX,qdaily.com,DIRECT
  - DOMAIN-SUFFIX,qdmm.com,DIRECT
  - DOMAIN-SUFFIX,qhimg.com,DIRECT
  - DOMAIN-SUFFIX,qhres.com,DIRECT
  - DOMAIN-SUFFIX,qidian.com,DIRECT
  - DOMAIN-SUFFIX,qihucdn.com,DIRECT
  - DOMAIN-SUFFIX,qiniu.com,DIRECT
  - DOMAIN-SUFFIX,qiniucdn.com,DIRECT
  - DOMAIN-SUFFIX,qiyipic.com,DIRECT
  - DOMAIN-SUFFIX,qq.com,DIRECT
  - DOMAIN-SUFFIX,qqurl.com,DIRECT
  - DOMAIN-SUFFIX,rarbg.to,DIRECT
  - DOMAIN-SUFFIX,ruguoapp.com,DIRECT
  - DOMAIN-SUFFIX,segmentfault.com,DIRECT
  - DOMAIN-SUFFIX,sinaapp.com,DIRECT
  - DOMAIN-SUFFIX,smzdm.com,DIRECT
  - DOMAIN-SUFFIX,sogou.com,DIRECT
  - DOMAIN-SUFFIX,sogoucdn.com,DIRECT
  - DOMAIN-SUFFIX,sohu.com,DIRECT
  - DOMAIN-SUFFIX,soku.com,DIRECT
  - DOMAIN-SUFFIX,speedtest.net,DIRECT
  - DOMAIN-SUFFIX,sspai.com,DIRECT
  - DOMAIN-SUFFIX,suning.com,DIRECT
  - DOMAIN-SUFFIX,taobao.com,DIRECT
  - DOMAIN-SUFFIX,tencent.com,DIRECT
  - DOMAIN-SUFFIX,tenpay.com,DIRECT
  - DOMAIN-SUFFIX,tianyancha.com,DIRECT
  - DOMAIN-SUFFIX,tmall.com,DIRECT
  - DOMAIN-SUFFIX,tudou.com,DIRECT
  - DOMAIN-SUFFIX,umetrip.com,DIRECT
  - DOMAIN-SUFFIX,upaiyun.com,DIRECT
  - DOMAIN-SUFFIX,upyun.com,DIRECT
  - DOMAIN-SUFFIX,veryzhun.com,DIRECT
  - DOMAIN-SUFFIX,weather.com,DIRECT
  - DOMAIN-SUFFIX,weibo.com,DIRECT
  - DOMAIN-SUFFIX,xiami.com,DIRECT
  - DOMAIN-SUFFIX,xiami.net,DIRECT
  - DOMAIN-SUFFIX,xiaomicp.com,DIRECT
  - DOMAIN-SUFFIX,ximalaya.com,DIRECT
  - DOMAIN-SUFFIX,xmcdn.com,DIRECT
  - DOMAIN-SUFFIX,xunlei.com,DIRECT
  - DOMAIN-SUFFIX,yhd.com,DIRECT
  - DOMAIN-SUFFIX,yihaodianimg.com,DIRECT
  - DOMAIN-SUFFIX,yinxiang.com,DIRECT
  - DOMAIN-SUFFIX,ykimg.com,DIRECT
  - DOMAIN-SUFFIX,youdao.com,DIRECT
  - DOMAIN-SUFFIX,youku.com,DIRECT
  - DOMAIN-SUFFIX,zealer.com,DIRECT
  - DOMAIN-SUFFIX,zhihu.com,DIRECT
  - DOMAIN-SUFFIX,zhimg.com,DIRECT
  - DOMAIN-SUFFIX,zimuzu.tv,DIRECT
  - DOMAIN-KEYWORD,netflix,CroLAX
  - DOMAIN-KEYWORD,nflx,CroLAX
  ## 抗 DNS 污染
  - DOMAIN-KEYWORD,amazon,CroLAX
  - DOMAIN-KEYWORD,google,CroLAX
  - DOMAIN-KEYWORD,gmail,CroLAX
  - DOMAIN-KEYWORD,youtube,CroLAX
  - DOMAIN-KEYWORD,facebook,CroLAX
  - DOMAIN-SUFFIX,fb.me,CroLAX
  - DOMAIN-SUFFIX,fbcdn.net,CroLAX
  - DOMAIN-KEYWORD,twitter,CroLAX
  - DOMAIN-KEYWORD,instagram,CroLAX
  - DOMAIN-KEYWORD,dropbox,CroLAX
  - DOMAIN-SUFFIX,twimg.com,CroLAX
  - DOMAIN-KEYWORD,blogspot,CroLAX
  - DOMAIN-SUFFIX,youtu.be,CroLAX
  - DOMAIN-KEYWORD,whatsapp,CroLAX
  - DOMAIN-KEYWORD,googleapis,CroLAX
# Clubhouse
  - DOMAIN-SUFFIX,clubhouse.com,CroLAX
  - DOMAIN-SUFFIX,clubhouseapi.com,CroLAX
  - DOMAIN-SUFFIX,joinclubhouse.com,CroLAX
  - DOMAIN-SUFFIX,clubhouseprod.s3.amazonaws.com,CroLAX
  - DOMAIN-SUFFIX,clubhouse.pubnub.com,CroLAX

  - DOMAIN-SUFFIX, ap-oversea-tls.agora.io, DIRECT
  - DOMAIN-SUFFIX, ap-oversea.agora.io, DIRECT
  - DOMAIN-SUFFIX, ap-oversea2.agora.io, DIRECT
  - DOMAIN-SUFFIX, report-oversea.agora.io, DIRECT
  - IP-CIDR, 3.0.163.78/32, DIRECT
  - IP-CIDR, 13.230.60.35/32, DIRECT
  - IP-CIDR, 23.248.191.103/32, DIRECT
  - IP-CIDR, 23.248.191.105/32, DIRECT
  - IP-CIDR, 23.98.43.152/32, DIRECT
  - IP-CIDR, 35.178.208.187/32, DIRECT
  - IP-CIDR, 45.40.48.11/32, DIRECT
  - IP-CIDR, 45.255.124.98/32, DIRECT
  - IP-CIDR, 45.255.124.100/32, DIRECT
  - IP-CIDR, 45.255.124.101/32, DIRECT
  - IP-CIDR, 45.255.124.104/32, DIRECT
  - IP-CIDR, 45.255.124.105/32, DIRECT
  - IP-CIDR, 45.255.124.107/32, DIRECT
  - IP-CIDR, 45.255.124.108/32, DIRECT
  - IP-CIDR, 45.255.124.109/32, DIRECT
  - IP-CIDR, 45.255.124.135/32, DIRECT
  - IP-CIDR, 50.17.126.121/32, DIRECT
  - IP-CIDR, 52.52.84.170/32, DIRECT
  - IP-CIDR, 52.58.56.244/32, DIRECT
  - IP-CIDR, 52.194.158.59/32, DIRECT
  - IP-CIDR, 52.221.46.208/32, DIRECT
  - IP-CIDR, 54.178.26.110/32, DIRECT
  - IP-CIDR, 69.28.51.148/32, DIRECT
  - IP-CIDR, 103.59.49.10/32, DIRECT
  - IP-CIDR, 103.65.41.166/32, DIRECT
  - IP-CIDR, 103.65.41.169/32, DIRECT
  - IP-CIDR, 103.98.18.181/32, DIRECT
  - IP-CIDR, 103.98.18.183/32, DIRECT
  - IP-CIDR, 103.98.18.184/32, DIRECT
  - IP-CIDR, 103.98.18.189/32, DIRECT
  - IP-CIDR, 120.227.115.126/32, DIRECT
  - IP-CIDR, 122.10.255.165/32, DIRECT
  - IP-CIDR, 128.1.87.196/32, DIRECT
  - IP-CIDR, 129.227.71.203/32, DIRECT
  - IP-CIDR, 129.227.115.130/32, DIRECT
  - IP-CIDR, 148.153.126.146/32, DIRECT
  - IP-CIDR, 148.153.172.73/32, DIRECT
  - IP-CIDR, 148.153.172.74/32, DIRECT
  - IP-CIDR, 148.153.172.75/32, DIRECT
  - IP-CIDR, 148.153.172.76/32, DIRECT
  - IP-CIDR, 148.153.172.77/32, DIRECT
  - IP-CIDR, 164.52.0.244/32, DIRECT
  - IP-CIDR, 164.52.6.19/32, DIRECT
  - IP-CIDR, 164.52.6.21/32, DIRECT
  - IP-CIDR, 164.52.6.23/32, DIRECT
  - IP-CIDR, 164.52.6.24/32, DIRECT
  - IP-CIDR, 164.52.6.25/32, DIRECT
  - IP-CIDR, 164.52.32.57/32, DIRECT
  - IP-CIDR, 164.52.32.59/32, DIRECT
  - IP-CIDR, 164.52.32.60/32, DIRECT
  - IP-CIDR, 164.52.36.228/32, DIRECT
  - IP-CIDR, 164.52.36.232/32, DIRECT
  - IP-CIDR, 164.52.36.238/32, DIRECT
  - IP-CIDR, 164.52.36.243/32, DIRECT
  - IP-CIDR, 164.52.36.245/32, DIRECT
  - IP-CIDR, 164.52.36.254/32, DIRECT
  - IP-CIDR, 164.52.102.35/32, DIRECT
  - IP-CIDR, 164.52.102.66/32, DIRECT
  - IP-CIDR, 164.52.102.67/32, DIRECT
  - IP-CIDR, 164.52.102.68/32, DIRECT
  - IP-CIDR, 164.52.102.69/32, DIRECT
  - IP-CIDR, 164.52.102.70/32, DIRECT
  - IP-CIDR, 164.52.102.75/32, DIRECT
  - IP-CIDR, 164.52.102.76/32, DIRECT
  - IP-CIDR, 164.52.102.77/32, DIRECT
  - IP-CIDR, 164.52.102.91/32, DIRECT
  - IP-CIDR, 164.52.124.102/32, DIRECT
  - IP-CIDR, 199.190.44.36/32, DIRECT
  - IP-CIDR, 199.190.44.37/32, DIRECT
  - IP-CIDR, 202.181.136.106/32, DIRECT
  - IP-CIDR, 202.226.25.162/32, DIRECT
  - IP-CIDR, 202.226.25.166/32, DIRECT
  - IP-CIDR, 202.226.25.171/32, DIRECT
  - IP-CIDR, 202.226.25.195/32, DIRECT
  - IP-CIDR, 202.226.25.198/32, DIRECT
  - IP-CIDR, 129.227.57.143/32, DIRECT
  - IP-CIDR, 129.227.234.70/32, DIRECT
  - IP-CIDR, 129.227.234.82/32, DIRECT
  - IP-CIDR, 129.227.234.119/32, DIRECT
  - IP-CIDR, 129.227.71.144/32, DIRECT
  - IP-CIDR, 129.227.57.132/32, DIRECT
  - IP-CIDR, 129.227.57.134/32, DIRECT
  - IP-CIDR, 129.227.57.145/32, DIRECT
  - IP-CIDR, 129.227.71.141/32, DIRECT
  - IP-CIDR, 129.227.234.83/32, DIRECT
  - IP-CIDR, 129.227.71.142/32, DIRECT
  - IP-CIDR, 129.227.71.132/32, DIRECT
  - IP-CIDR, 129.227.71.133/32, DIRECT
  - IP-CIDR, 129.227.71.134/32, DIRECT
  - IP-CIDR, 129.227.234.67/32, DIRECT
  - IP-CIDR, 129.227.234.110/32, DIRECT
  - IP-CIDR, 129.227.234.112/32, DIRECT
  - IP-CIDR, 129.227.234.124/32, DIRECT
  - IP-CIDR, 129.227.71.140/32, DIRECT
  - IP-CIDR, 129.227.71.130/32, DIRECT
  - IP-CIDR, 129.227.71.131/32, DIRECT
  - IP-CIDR, 129.227.71.143/32, DIRECT
  - IP-CIDR, 129.227.156.17/32, DIRECT
  - IP-CIDR, 129.227.57.137/32, DIRECT
  - IP-CIDR, 129.227.156.20/32, DIRECT
  ## 国外网站
  - DOMAIN-SUFFIX,openai.com,CroLAX
  - DOMAIN-SUFFIX,9to5mac.com,CroLAX
  - DOMAIN-SUFFIX,abpchina.org,CroLAX
  - DOMAIN-SUFFIX,adblockplus.org,CroLAX
  - DOMAIN-SUFFIX,adobe.com,CroLAX
  - DOMAIN-SUFFIX,alfredapp.com,CroLAX
  - DOMAIN-SUFFIX,amplitude.com,CroLAX
  - DOMAIN-SUFFIX,ampproject.org,CroLAX
  - DOMAIN-SUFFIX,android.com,CroLAX
  - DOMAIN-SUFFIX,angularjs.org,CroLAX
  - DOMAIN-SUFFIX,aolcdn.com,CroLAX
  - DOMAIN-SUFFIX,apkpure.com,CroLAX
  - DOMAIN-SUFFIX,appledaily.com,CroLAX
  - DOMAIN-SUFFIX,appshopper.com,CroLAX
  - DOMAIN-SUFFIX,appspot.com,CroLAX
  - DOMAIN-SUFFIX,arcgis.com,CroLAX
  - DOMAIN-SUFFIX,archive.org,CroLAX
  - DOMAIN-SUFFIX,armorgames.com,CroLAX
  - DOMAIN-SUFFIX,aspnetcdn.com,CroLAX
  - DOMAIN-SUFFIX,att.com,CroLAX
  - DOMAIN-SUFFIX,awsstatic.com,CroLAX
  - DOMAIN-SUFFIX,azureedge.net,CroLAX
  - DOMAIN-SUFFIX,azurewebsites.net,CroLAX
  - DOMAIN-SUFFIX,bing.com,CroLAX
  - DOMAIN-SUFFIX,bintray.com,CroLAX
  - DOMAIN-SUFFIX,bit.com,CroLAX
  - DOMAIN-SUFFIX,bit.ly,CroLAX
  - DOMAIN-SUFFIX,bitbucket.org,CroLAX
  - DOMAIN-SUFFIX,bjango.com,CroLAX
  - DOMAIN-SUFFIX,bkrtx.com,CroLAX
  - DOMAIN-SUFFIX,blog.com,CroLAX
  - DOMAIN-SUFFIX,blogcdn.com,CroLAX
  - DOMAIN-SUFFIX,blogger.com,CroLAX
  - DOMAIN-SUFFIX,blogsmithmedia.com,CroLAX
  - DOMAIN-SUFFIX,blogspot.com,CroLAX
  - DOMAIN-SUFFIX,blogspot.hk,CroLAX
  - DOMAIN-SUFFIX,bloomberg.com,CroLAX
  - DOMAIN-SUFFIX,box.com,CroLAX
  - DOMAIN-SUFFIX,box.net,CroLAX
  - DOMAIN-SUFFIX,cachefly.net,CroLAX
  - DOMAIN-SUFFIX,chromium.org,CroLAX
  - DOMAIN-SUFFIX,cl.ly,CroLAX
  - DOMAIN-SUFFIX,cloudflare.com,CroLAX
  - DOMAIN-SUFFIX,cloudfront.net,CroLAX
  - DOMAIN-SUFFIX,cloudmagic.com,CroLAX
  - DOMAIN-SUFFIX,cmail19.com,CroLAX
  - DOMAIN-SUFFIX,cnet.com,CroLAX
  - DOMAIN-SUFFIX,cocoapods.org,CroLAX
  - DOMAIN-SUFFIX,comodoca.com,CroLAX
  - DOMAIN-SUFFIX,crashlytics.com,CroLAX
  - DOMAIN-SUFFIX,culturedcode.com,CroLAX
  - DOMAIN-SUFFIX,d.pr,CroLAX
  - DOMAIN-SUFFIX,danilo.to,CroLAX
  - DOMAIN-SUFFIX,dayone.me,CroLAX
  - DOMAIN-SUFFIX,db.tt,CroLAX
  - DOMAIN-SUFFIX,deskconnect.com,CroLAX
  - DOMAIN-SUFFIX,disq.us,CroLAX
  - DOMAIN-SUFFIX,disqus.com,CroLAX
  - DOMAIN-SUFFIX,disquscdn.com,CroLAX
  - DOMAIN-SUFFIX,dnsimple.com,CroLAX
  - DOMAIN-SUFFIX,docker.com,CroLAX
  - DOMAIN-SUFFIX,dribbble.com,CroLAX
  - DOMAIN-SUFFIX,droplr.com,CroLAX
  - DOMAIN-SUFFIX,duckduckgo.com,CroLAX
  - DOMAIN-SUFFIX,dueapp.com,CroLAX
  - DOMAIN-SUFFIX,dytt8.net,CroLAX
  - DOMAIN-SUFFIX,edgecastcdn.net,CroLAX
  - DOMAIN-SUFFIX,edgekey.net,CroLAX
  - DOMAIN-SUFFIX,edgesuite.net,CroLAX
  - DOMAIN-SUFFIX,engadget.com,CroLAX
  - DOMAIN-SUFFIX,entrust.net,CroLAX
  - DOMAIN-SUFFIX,eurekavpt.com,CroLAX
  - DOMAIN-SUFFIX,evernote.com,CroLAX
  - DOMAIN-SUFFIX,fabric.io,CroLAX
  - DOMAIN-SUFFIX,fast.com,CroLAX
  - DOMAIN-SUFFIX,fastly.net,CroLAX
  - DOMAIN-SUFFIX,fc2.com,CroLAX
  - DOMAIN-SUFFIX,feedburner.com,CroLAX
  - DOMAIN-SUFFIX,feedly.com,CroLAX
  - DOMAIN-SUFFIX,feedsportal.com,CroLAX
  - DOMAIN-SUFFIX,fiftythree.com,CroLAX
  - DOMAIN-SUFFIX,firebaseio.com,CroLAX
  - DOMAIN-SUFFIX,flexibits.com,CroLAX
  - DOMAIN-SUFFIX,flickr.com,CroLAX
  - DOMAIN-SUFFIX,flipboard.com,CroLAX
  - DOMAIN-SUFFIX,g.co,CroLAX
  - DOMAIN-SUFFIX,gabia.net,CroLAX
  - DOMAIN-SUFFIX,geni.us,CroLAX
  - DOMAIN-SUFFIX,gfx.ms,CroLAX
  - DOMAIN-SUFFIX,ggpht.com,CroLAX
  - DOMAIN-SUFFIX,ghostnoteapp.com,CroLAX
  - DOMAIN-SUFFIX,git.io,CroLAX
  - DOMAIN-KEYWORD,github,CroLAX
  - DOMAIN-KEYWORD,linkedin,CroLAX
  - DOMAIN-SUFFIX,globalsign.com,CroLAX
  - DOMAIN-SUFFIX,gmodules.com,CroLAX
  - DOMAIN-SUFFIX,godaddy.com,CroLAX
  - DOMAIN-SUFFIX,golang.org,CroLAX
  - DOMAIN-SUFFIX,gongm.in,CroLAX
  - DOMAIN-SUFFIX,goo.gl,CroLAX
  - DOMAIN-SUFFIX,goodreaders.com,CroLAX
  - DOMAIN-SUFFIX,goodreads.com,CroLAX
  - DOMAIN-SUFFIX,gravatar.com,CroLAX
  - DOMAIN-SUFFIX,gstatic.com,CroLAX
  - DOMAIN-SUFFIX,gvt0.com,CroLAX
  - DOMAIN-SUFFIX,hockeyapp.net,CroLAX
  - DOMAIN-SUFFIX,hotmail.com,CroLAX
  - DOMAIN-SUFFIX,icons8.com,CroLAX
  - DOMAIN-SUFFIX,ift.tt,CroLAX
  - DOMAIN-SUFFIX,ifttt.com,CroLAX
  - DOMAIN-SUFFIX,iherb.com,CroLAX
  - DOMAIN-SUFFIX,imageshack.us,CroLAX
  - DOMAIN-SUFFIX,img.ly,CroLAX
  - DOMAIN-SUFFIX,imgur.com,CroLAX
  - DOMAIN-SUFFIX,imore.com,CroLAX
  - DOMAIN-SUFFIX,instapaper.com,CroLAX
  - DOMAIN-SUFFIX,ipn.li,CroLAX
  - DOMAIN-SUFFIX,is.gd,CroLAX
  - DOMAIN-SUFFIX,issuu.com,CroLAX
  - DOMAIN-SUFFIX,itgonglun.com,CroLAX
  - DOMAIN-SUFFIX,itun.es,CroLAX
  - DOMAIN-SUFFIX,ixquick.com,CroLAX
  - DOMAIN-SUFFIX,j.mp,CroLAX
  - DOMAIN-SUFFIX,js.revsci.net,CroLAX
  - DOMAIN-SUFFIX,jshint.com,CroLAX
  - DOMAIN-SUFFIX,jtvnw.net,CroLAX
  - DOMAIN-SUFFIX,justgetflux.com,CroLAX
  - DOMAIN-SUFFIX,kat.cr,CroLAX
  - DOMAIN-SUFFIX,klip.me,CroLAX
  - DOMAIN-SUFFIX,libsyn.com,CroLAX
  - DOMAIN-SUFFIX,linode.com,CroLAX
  - DOMAIN-SUFFIX,lithium.com,CroLAX
  - DOMAIN-SUFFIX,littlehj.com,CroLAX
  - DOMAIN-SUFFIX,live.com,CroLAX
  - DOMAIN-SUFFIX,live.net,CroLAX
  - DOMAIN-SUFFIX,livefilestore.com,CroLAX
  - DOMAIN-SUFFIX,llnwd.net,CroLAX
  - DOMAIN-SUFFIX,macid.co,CroLAX
  - DOMAIN-SUFFIX,macromedia.com,CroLAX
  - DOMAIN-SUFFIX,macrumors.com,CroLAX
  - DOMAIN-SUFFIX,mashable.com,CroLAX
  - DOMAIN-SUFFIX,mathjax.org,CroLAX
  - DOMAIN-SUFFIX,medium.com,CroLAX
  - DOMAIN-SUFFIX,mega.co.nz,CroLAX
  - DOMAIN-SUFFIX,mega.nz,CroLAX
  - DOMAIN-SUFFIX,megaupload.com,CroLAX
  - DOMAIN-SUFFIX,microsofttranslator.com,CroLAX
  - DOMAIN-SUFFIX,mindnode.com,CroLAX
  - DOMAIN-SUFFIX,mobile01.com,CroLAX
  - DOMAIN-SUFFIX,modmyi.com,CroLAX
  - DOMAIN-SUFFIX,msedge.net,CroLAX
  - DOMAIN-SUFFIX,myfontastic.com,CroLAX
  - DOMAIN-SUFFIX,name.com,CroLAX
  - DOMAIN-SUFFIX,nextmedia.com,CroLAX
  - DOMAIN-SUFFIX,nsstatic.net,CroLAX
  - DOMAIN-SUFFIX,nssurge.com,CroLAX
  - DOMAIN-SUFFIX,nyt.com,CroLAX
  - DOMAIN-SUFFIX,nytimes.com,CroLAX
  - DOMAIN-SUFFIX,omnigroup.com,CroLAX
  - DOMAIN-SUFFIX,onedrive.com,CroLAX
  - DOMAIN-SUFFIX,onenote.com,CroLAX
  - DOMAIN-SUFFIX,ooyala.com,CroLAX
  - DOMAIN-SUFFIX,openvpn.net,CroLAX
  - DOMAIN-SUFFIX,openwrt.org,CroLAX
  - DOMAIN-SUFFIX,orkut.com,CroLAX
  - DOMAIN-SUFFIX,osxdaily.com,CroLAX
  - DOMAIN-SUFFIX,outlook.com,CroLAX
  - DOMAIN-SUFFIX,ow.ly,CroLAX
  - DOMAIN-SUFFIX,paddleapi.com,CroLAX
  - DOMAIN-SUFFIX,parallels.com,CroLAX
  - DOMAIN-SUFFIX,parse.com,CroLAX
  - DOMAIN-SUFFIX,pdfexpert.com,CroLAX
  - DOMAIN-SUFFIX,periscope.tv,CroLAX
  - DOMAIN-SUFFIX,pinboard.in,CroLAX
  - DOMAIN-SUFFIX,pinterest.com,CroLAX
  - DOMAIN-SUFFIX,pixelmator.com,CroLAX
  - DOMAIN-SUFFIX,pixiv.net,CroLAX
  - DOMAIN-SUFFIX,playpcesor.com,CroLAX
  - DOMAIN-SUFFIX,playstation.com,CroLAX
  - DOMAIN-SUFFIX,playstation.com.hk,CroLAX
  - DOMAIN-SUFFIX,playstation.net,CroLAX
  - DOMAIN-SUFFIX,playstationnetwork.com,CroLAX
  - DOMAIN-SUFFIX,pushwoosh.com,CroLAX
  - DOMAIN-SUFFIX,rime.im,CroLAX
  - DOMAIN-SUFFIX,servebom.com,CroLAX
  - DOMAIN-SUFFIX,sfx.ms,CroLAX
  - DOMAIN-SUFFIX,shadowsocks.org,CroLAX
  - DOMAIN-SUFFIX,sharethis.com,CroLAX
  - DOMAIN-SUFFIX,shazam.com,CroLAX
  - DOMAIN-SUFFIX,skype.com,CroLAX
  - DOMAIN-SUFFIX,smartdnsProxy.com,CroLAX
  - DOMAIN-SUFFIX,smartmailcloud.com,CroLAX
  - DOMAIN-SUFFIX,sndcdn.com,CroLAX
  - DOMAIN-SUFFIX,sony.com,CroLAX
  - DOMAIN-SUFFIX,soundcloud.com,CroLAX
  - DOMAIN-SUFFIX,sourceforge.net,CroLAX
  - DOMAIN-SUFFIX,spotify.com,CroLAX
  - DOMAIN-SUFFIX,squarespace.com,CroLAX
  - DOMAIN-SUFFIX,sstatic.net,CroLAX
  - DOMAIN-SUFFIX,st.luluku.pw,CroLAX
  - DOMAIN-SUFFIX,stackoverflow.com,CroLAX
  - DOMAIN-SUFFIX,startpage.com,CroLAX
  - DOMAIN-SUFFIX,staticflickr.com,CroLAX
  - DOMAIN-SUFFIX,steamcommunity.com,CroLAX
  - DOMAIN-SUFFIX,symauth.com,CroLAX
  - DOMAIN-SUFFIX,symcb.com,CroLAX
  - DOMAIN-SUFFIX,symcd.com,CroLAX
  - DOMAIN-SUFFIX,tapbots.com,CroLAX
  - DOMAIN-SUFFIX,tapbots.net,CroLAX
  - DOMAIN-SUFFIX,tdesktop.com,CroLAX
  - DOMAIN-SUFFIX,techcrunch.com,CroLAX
  - DOMAIN-SUFFIX,techsmith.com,CroLAX
  - DOMAIN-SUFFIX,thepiratebay.org,CroLAX
  - DOMAIN-SUFFIX,theverge.com,CroLAX
  - DOMAIN-SUFFIX,time.com,CroLAX
  - DOMAIN-SUFFIX,timeinc.net,CroLAX
  - DOMAIN-SUFFIX,tiny.cc,CroLAX
  - DOMAIN-SUFFIX,tinypic.com,CroLAX
  - DOMAIN-SUFFIX,tmblr.co,CroLAX
  - DOMAIN-SUFFIX,todoist.com,CroLAX
  - DOMAIN-SUFFIX,trello.com,CroLAX
  - DOMAIN-SUFFIX,trustasiassl.com,CroLAX
  - DOMAIN-SUFFIX,tumblr.co,CroLAX
  - DOMAIN-SUFFIX,tumblr.com,CroLAX
  - DOMAIN-SUFFIX,tweetdeck.com,CroLAX
  - DOMAIN-SUFFIX,tweetmarker.net,CroLAX
  - DOMAIN-SUFFIX,twitch.tv,CroLAX
  - DOMAIN-SUFFIX,txmblr.com,CroLAX
  - DOMAIN-SUFFIX,typekit.net,CroLAX
  - DOMAIN-SUFFIX,ubertags.com,CroLAX
  - DOMAIN-SUFFIX,ublock.org,CroLAX
  - DOMAIN-SUFFIX,ubnt.com,CroLAX
  - DOMAIN-SUFFIX,ulyssesapp.com,CroLAX
  - DOMAIN-SUFFIX,urchin.com,CroLAX
  - DOMAIN-SUFFIX,usertrust.com,CroLAX
  - DOMAIN-SUFFIX,v.gd,CroLAX
  - DOMAIN-SUFFIX,vimeo.com,CroLAX
  - DOMAIN-SUFFIX,vimeocdn.com,CroLAX
  - DOMAIN-SUFFIX,vine.co,CroLAX
  - DOMAIN-SUFFIX,vivaldi.com,CroLAX
  - DOMAIN-SUFFIX,vox-cdn.com,CroLAX
  - DOMAIN-SUFFIX,vsco.co,CroLAX
  - DOMAIN-SUFFIX,vultr.com,CroLAX
  - DOMAIN-SUFFIX,w.org,CroLAX
  - DOMAIN-SUFFIX,w3schools.com,CroLAX
  - DOMAIN-SUFFIX,webtype.com,CroLAX
  - DOMAIN-SUFFIX,wikiwand.com,CroLAX
  - DOMAIN-SUFFIX,wikileaks.org,CroLAX
  - DOMAIN-SUFFIX,wikimedia.org,CroLAX
  - DOMAIN-SUFFIX,wikipedia.com,CroLAX
  - DOMAIN-SUFFIX,wikipedia.org,CroLAX
  - DOMAIN-SUFFIX,windows.com,CroLAX
  - DOMAIN-SUFFIX,windows.net,CroLAX
  - DOMAIN-SUFFIX,wire.com,CroLAX
  - DOMAIN-SUFFIX,wordpress.com,CroLAX
  - DOMAIN-SUFFIX,workflowy.com,CroLAX
  - DOMAIN-SUFFIX,wp.com,CroLAX
  - DOMAIN-SUFFIX,wsj.com,CroLAX
  - DOMAIN-SUFFIX,wsj.net,CroLAX
  - DOMAIN-SUFFIX,xda-developers.com,CroLAX
  - DOMAIN-SUFFIX,xeeno.com,CroLAX
  - DOMAIN-SUFFIX,xiti.com,CroLAX
  - DOMAIN-SUFFIX,yahoo.com,CroLAX
  - DOMAIN-SUFFIX,yimg.com,CroLAX
  - DOMAIN-SUFFIX,ying.com,CroLAX
  - DOMAIN-SUFFIX,yoyo.org,CroLAX
  - DOMAIN-SUFFIX,ytimg.com,CroLAX
  - DOMAIN-SUFFIX,telegram.me,CroLAX
  - DOMAIN-SUFFIX,v2ex.com,CroLAX
  - DOMAIN-SUFFIX,poe.com,CroLAX
  - DOMAIN-SUFFIX,poecdn.net,CroLAX
  - DOMAIN-SUFFIX,quoracdn.net,CroLAX
  - IP-CIDR,91.108.4.0/22,CroLAX
  - IP-CIDR,91.108.8.0/22,CroLAX
  - IP-CIDR,91.108.56.0/22,CroLAX
  - IP-CIDR,109.239.140.0/24,CroLAX
  - IP-CIDR,149.154.160.0/20,CroLAX
  - IP-CIDR,127.0.0.0/8,DIRECT
  - IP-CIDR,172.16.0.0/12,DIRECT
  - IP-CIDR,192.168.0.0/16,DIRECT
  - IP-CIDR,10.0.0.0/8,DIRECT
  - IP-CIDR,17.0.0.0/8,DIRECT
  - IP-CIDR,100.64.0.0/10,DIRECT
  - GEOIP,CN,DIRECT
  - MATCH,CroLAX
&lt;/code&gt;&lt;/pre&gt;</content:encoded><h:img src="/_astro/web.n3Pk-HlC.jpg"/><enclosure url="/_astro/web.n3Pk-HlC.jpg"/></item><item><title>闲着没事写了个算命库，用来算老板什么时候倒大霉</title><link>https://hejunjie.life/blog/74ce4601</link><guid isPermaLink="true">https://hejunjie.life/blog/74ce4601</guid><description>利用 PHP 实现的命理计算库，支持阳历与农历转换、精准节气时间计算、四柱八字排盘、五行推演、十神分析与大运起运计算。适合程序员深入理解传统命理算法背后的逻辑与计算方式，兼具实用与娱乐。附完整源码与算法说明</description><pubDate>Wed, 11 Jun 2025 02:01:21 GMT</pubDate><content:encoded>&lt;p&gt;最近被家里长辈念叨得有点多，动不动就说我什么五行缺金、八字偏弱，还得配个什么什么的名字才行。加上我最近一边吃饭一边看台湾民宿的恐怖片，突然对这些玄学有了一点点兴趣。&lt;/p&gt;
&lt;p&gt;虽然我不太信这个东西，但我确实对“它是怎么算出来的”很好奇。你要说它是骗人吧，它又有一整套公式流程，感觉好像还挺讲究的。&lt;/p&gt;
&lt;p&gt;于是就动手写了这个东西：一个命理计算的 PHP 库，能自己算八字、推五行、排大运，还能告诉你老板哪年可能倒霉，图一乐。&lt;/p&gt;
&lt;h2&gt;这玩意儿能干啥？&lt;/h2&gt;
&lt;p&gt;这个库支持的功能大概有这些：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;阳历 ⇄ 农历转换&lt;/li&gt;
&lt;li&gt;节气时间（真的准，用天文算法算的）&lt;/li&gt;
&lt;li&gt;年柱、月柱、日柱、时柱（也就是完整的八字）&lt;/li&gt;
&lt;li&gt;五行统计（带不带藏干都能看）&lt;/li&gt;
&lt;li&gt;十神（正印、偏财、比肩这些）&lt;/li&gt;
&lt;li&gt;起运年龄计算、大运列表排出来&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;代码怎么写出来的？&lt;/h2&gt;
&lt;p&gt;说实话，像五行生克、天干地支这些东西，对程序员来说真不算难，本质上也就是一堆映射表 + 逻辑判断而已。&lt;br&gt;
真正能把人搞崩溃的，其实是节气计算 —— 这玩意简直是个天坑。&lt;/p&gt;
&lt;p&gt;因为它根本不是按日历走的，而是个纯粹的天文系统，更像太阳历。这里顺便说个冷知识：&lt;/p&gt;
&lt;p&gt;想象一下太阳每天中午挂在天上的位置：今天高一点，明天低一点……&lt;br&gt;
如果你把它一年里的这些位置连成一圈，就会形成一个完美的大圆 —— 这就是所谓的 &lt;strong&gt;黄道&lt;/strong&gt;。&lt;br&gt;
节气，其实就是把这个黄道平均切成 24 份，每隔 15° 算一个节气。&lt;/p&gt;
&lt;p&gt;为了把节气时间算精准，我硬着头皮研究了一堆东西：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;太阳黄经计算（要考虑地球绕太阳转的椭圆轨道）&lt;/li&gt;
&lt;li&gt;时差修正（真太阳时 vs 平太阳时）&lt;/li&gt;
&lt;li&gt;岁差影响（地球自转轴其实一直在缓慢打摆子）&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;写这段代码的时候，我掉的头发比 debug 时打的 &lt;code&gt;console.log&lt;/code&gt;​ 还多……&lt;br&gt;
最离谱的是，最后测试时发现我的节气时间和紫金山天文台的数据差了几分钟 ——&lt;br&gt;
结果翻了半天，才发现：&lt;strong&gt;哦，地球自转减速了。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;但既然我是做个独立的 composer 包，不能靠第三方 API，每次都实时拉数据也不现实，&lt;br&gt;
最后还是咬咬牙去参考了 NASA 的数据，硬把公式补齐，才算基本搞定了。&lt;/p&gt;
&lt;h2&gt;一些不正经的用途&lt;/h2&gt;
&lt;p&gt;经过多次&quot;科学验证&quot;，我发现：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;当老板批评我时，就查查他是不是今天&quot;犯冲&quot;&lt;/li&gt;
&lt;li&gt;同事甩锅时，看看我们八字是不是相克&lt;/li&gt;
&lt;li&gt;最实用的是——算出自己&quot;财运&quot;好的日子，然后在那天摸鱼&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;最后说两句&lt;/h2&gt;
&lt;p&gt;这个项目不是要宣扬迷信，而是试图用科学的方法去理解和实现传统命理学中的计算部分。它或许不能预测未来，但至少能让你明白那些&quot;大师&quot;是怎么算出你的八字的。&lt;/p&gt;
&lt;p&gt;最后，如果你也想&quot;科学地&quot;算算自己什么时候能发财，或者单纯对传统历法算法感兴趣，欢迎来GitHub给个star⭐！&lt;/p&gt;
&lt;p&gt;GitHub地址：&lt;a href=&quot;https://github.com/zxc7563598/php-fortune-analyzer&quot;&gt;https://github.com/zxc7563598/php-fortune-analyzer&lt;/a&gt;​&lt;/p&gt;</content:encoded><h:img src="/_astro/php.DoApYH8-.png"/><enclosure url="/_astro/php.DoApYH8-.png"/></item><item><title>一招解决提交信息混乱 —— 教你优雅搞定 Git 提交规范！</title><link>https://hejunjie.life/blog/1cf99112</link><guid isPermaLink="true">https://hejunjie.life/blog/1cf99112</guid><description>这篇文章教你如何用 Husky + Commitlint + cz-customizable 快速搭建一套 Git 提交规范系统，强制校验提交格式的同时，还能通过交互式方式生成标准提交说明。适用于前端、后端、团队协作项目，附完整配置文件和使用技巧，轻松提升代码管理质量</description><pubDate>Sun, 01 Jun 2025 02:39:37 GMT</pubDate><content:encoded>&lt;p&gt;最近折腾项目的时候，发现一个一直被忽略的小问题——&lt;strong&gt;Git 提交信息太随意了！&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;有的写中文、有的全英文，有的直接就：&lt;code&gt;修改了&lt;/code&gt;​……&lt;/p&gt;
&lt;p&gt;每次看 Git log 都跟看天书一样，谁做了啥完全没头绪。而我又不想像个管理员一样天天盯着提醒“你提交信息写规范点行不行 😩”。&lt;/p&gt;
&lt;p&gt;于是我找到了一个小而美的方案，能：&lt;/p&gt;
&lt;p&gt;✅ 自动检查提交格式，拦截不规范提交&lt;br&gt;
✅ 提交时弹出提示，&lt;strong&gt;引导式&lt;/strong&gt;填写提交说明&lt;br&gt;
✅ 配置简单，不依赖 Gitlab/Jenkins 之类 CI 系统&lt;/p&gt;
&lt;p&gt;很适合像我一样刚开始重视团队协作规范的小伙伴！&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;我用了什么方案？&lt;/h2&gt;
&lt;p&gt;两个关键词：&lt;/p&gt;
&lt;h3&gt;Husky&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;帮你拦住那些“不合格”的提交&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;它会在你执行 &lt;code&gt;git commit&lt;/code&gt;​ 的时候，自动执行一些脚本，比如：检查你的提交信息是不是符合规范，并且拒绝那些不规范的提交&lt;/p&gt;
&lt;h3&gt;cz-customizable&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;提交时弹个窗，你只用选和填&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;它在提交时弹出一个选择框，比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;请选择你做了啥？
❯ feat: 新功能
  fix: 修复 Bug
  docs: 文档更新
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后一步步填完后自动帮你拼出标准格式，比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;feat(core): 新增用户登录功能
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;是不是比自己记格式轻松多了！&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;怎么一步步搞定？&lt;/h2&gt;
&lt;p&gt;下面这些命令和配置都是我自己实践过的，跟着一步步来就行：&lt;/p&gt;
&lt;h3&gt;安装必要依赖&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install --save-dev husky cz-customizable commitlint @commitlint/config-conventional
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;初始化 Husky&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx husky install
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;该命令会在项目根目录创建 &lt;code&gt;.husky/&lt;/code&gt;​ 目录&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;然后执行：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;npm pkg set scripts.prepare=&quot;husky install&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;这步是为了确保别人 clone 项目后，执行 &lt;code&gt;npm install&lt;/code&gt;​ 时自动初始化好 Husky。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3&gt;添加一个 Git 钩子，用来检查提交格式&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;mkdir -p .husky

echo &apos;npx commitlint --edit &quot;$1&quot;&apos; &gt; .husky/commit-msg

chmod +x .husky/commit-msg
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3&gt;添加配置文件&lt;/h3&gt;
&lt;p&gt;在项目根目录创建一个文件来设置提交信息校验规则： &lt;code&gt;commitlint.config.js&lt;/code&gt;​&lt;/p&gt;
&lt;p&gt;内容如下：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;module.exports = {
  extends: [&quot;@commitlint/config-conventional&quot;],
  rules: {
    &quot;type-enum&quot;: [
      2,
      &quot;always&quot;,
      [
        &quot;feat&quot;,
        &quot;fix&quot;,
        &quot;docs&quot;,
        &quot;style&quot;,
        &quot;refactor&quot;,
        &quot;perf&quot;,
        &quot;test&quot;,
        &quot;build&quot;,
        &quot;ci&quot;,
        &quot;chore&quot;,
        &quot;revert&quot;,
        &quot;temp&quot;,
        &quot;hotfix&quot;,
      ],
    ],
    &quot;subject-empty&quot;: [2, &quot;never&quot;],
    &quot;type-empty&quot;: [2, &quot;never&quot;],
    &quot;scope-case&quot;: [0],
  },
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;什么意思？简单翻译一下：&lt;/p&gt;
&lt;p&gt;​&lt;code&gt;extends: [&quot;@commitlint/config-conventional&quot;]&lt;/code&gt;​：表示继承了 &lt;code&gt;@commitlint/config-conventional&lt;/code&gt;​ 这个预设配置，它本身就定义了一套 &lt;a href=&quot;https://www.conventionalcommits.org/&quot;&gt;Conventional Commits&lt;/a&gt;（约定式提交）规范，比如 &lt;code&gt;feat: 添加新功能&lt;/code&gt;​、&lt;code&gt;fix: 修复 bug&lt;/code&gt;​。&lt;/p&gt;
&lt;p&gt;​&lt;code&gt;&apos;type-enum&apos;:[2,&apos;always&apos;,[...]]&lt;/code&gt;​：指定允许的提交类型（&lt;code&gt;[等级, 应用时机, 参数]&lt;/code&gt;​）&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;​&lt;code&gt;等级&lt;/code&gt;​：0-不启用规则，1-警告(不阻止提交)，2-错误(阻止提交)&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;​&lt;code&gt;应用时机&lt;/code&gt;​：&lt;code&gt;always&lt;/code&gt;​(总是应用这个规则)，&lt;code&gt;never&lt;/code&gt;​(永不应用这个规则)&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;​&lt;code&gt;参数&lt;/code&gt;​：允许的提交类型列表&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;​&lt;code&gt;&apos;subject-empty&apos;:[2, &apos;never&apos;]&lt;/code&gt;​：不允许提交信息的描述（subject）为空，比如 &lt;code&gt;fix:&lt;/code&gt;​ 这种就是不允许的&lt;/p&gt;
&lt;p&gt;​&lt;code&gt;&apos;type-empty&apos;: [2, &apos;never&apos;]&lt;/code&gt;​：不允许提交信息的类型（type）为空，比如 &lt;code&gt;: 修复了问题&lt;/code&gt;​ 这种就是不允许的&lt;/p&gt;
&lt;p&gt;​&lt;code&gt;&apos;scope-case&apos;: [0]&lt;/code&gt;​：关闭 &lt;code&gt;scope&lt;/code&gt;​ 的大小写检查（0 表示关闭该规则），scope 是指像 &lt;code&gt;fix(api):&lt;/code&gt;​ 中的 &lt;code&gt;api&lt;/code&gt;​。&lt;/p&gt;
&lt;p&gt;此时，整个项目就已经呗配置好了，在这种情况下，被允许提交的格式如下：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;✅ feat(core): 增加权限中间件功能
✅ fix(route): 修复 POST 接口匹配失败
✅ docs: 更新 README 文档
❌ 更新了配置文件（因为没有 type）
❌ build:   （因为没有 subject）
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3&gt;最后一步：配置 &lt;code&gt;cz-customizable&lt;/code&gt;​ 提示&lt;/h3&gt;
&lt;p&gt;添加 &lt;code&gt;.cz-config.js&lt;/code&gt;​，这是我自己用的一份配置，可以自由加减修改：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;module.exports = {
  types: [
    { value: &quot;feat&quot;, name: &quot;feat: 新功能&quot; },
    { value: &quot;fix&quot;, name: &quot;fix: 修复 Bug&quot; },
    { value: &quot;docs&quot;, name: &quot;docs: 文档变更&quot; },
    { value: &quot;style&quot;, name: &quot;style: 代码格式(不影响功能)&quot; },
    {
      value: &quot;refactor&quot;,
      name: &quot;refactor: 重构(既不是新增功能，也不是修复 Bug)&quot;,
    },
    { value: &quot;perf&quot;, name: &quot;perf: 性能优化&quot; },
    { value: &quot;test&quot;, name: &quot;test: 增加测试&quot; },
    { value: &quot;build&quot;, name: &quot;build: 构建相关的修改(如配置文件)&quot; },
    { value: &quot;ci&quot;, name: &quot;ci: CI 配置修改&quot; },
    { value: &quot;chore&quot;, name: &quot;chore: 其他杂项&quot; },
    { value: &quot;revert&quot;, name: &quot;revert: 回滚提交&quot; },
    { value: &quot;temp&quot;, name: &quot;temp: 临时提交&quot; },
    { value: &quot;hotfix&quot;, name: &quot;hotfix: 紧急修复&quot; },
  ],

  scopes: [
    { name: &quot;api&quot; },
    { name: &quot;model&quot; },
    { name: &quot;core&quot; },
    { name: &quot;middleware&quot; },
    { name: &quot;config&quot; },
    { name: &quot;migration&quot; },
    { name: &quot;seeder&quot; },
    { name: &quot;view&quot; },
    { name: &quot;route&quot; },
    { name: &quot;helper&quot; },
    { name: &quot;exception&quot; },
    { name: &quot;lang&quot; },
    { name: &quot;log&quot; },
    { name: &quot;test&quot; },
    { name: &quot;composer&quot; },
    { name: &quot;env&quot; },
    { name: &quot;ci&quot; },
    { name: &quot;docs&quot; },
    { name: &quot;assets&quot; },
    { name: &quot;cron&quot; },
    { name: &quot;queue&quot; },
  ],

  usePreparedCommit: false,
  allowTicketNumber: false,
  isTicketNumberRequired: false,
  ticketNumberPrefix: &quot;TICKET-&quot;,
  ticketNumberRegExp: &quot;\\d{1,5}&quot;,

  messages: {
    type: &quot;请选择您要提交的更改类型：&quot;,
    scope: &quot;\n请标明此次更改的范围（可选）：&quot;,
    customScope: &quot;请标明此次更改的范围：&quot;,
    subject: &quot;请用简短的祈使句描述此次更改：\n&quot;,
    body: &quot;提供更详细的更改描述（可选）。使用“|”换行：\n&quot;,
    breaking: &quot;列出任何重大更改（可选）：\n&quot;,
    footer: &quot;列出此更改关闭的相关问题（可选），例如：#31，#34：\n&quot;,
    confirmCommit: &quot;您确定要执行上述提交吗？&quot;,
  },

  allowCustomScopes: true,
  allowBreakingChanges: [&quot;feat&quot;, &quot;fix&quot;],

  subjectLimit: 100,
};
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;使用方式&lt;/h2&gt;
&lt;p&gt;以后提交就不要用 &lt;code&gt;git commit&lt;/code&gt;​ 了，直接：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx cz
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;接下来按提示一步步选：&lt;/p&gt;
&lt;p&gt;✅ 是什么类型？&lt;br&gt;
✅ 改动了哪一块？&lt;br&gt;
✅ 简单说说做了啥？&lt;/p&gt;
&lt;p&gt;最后它会自动生成类似：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;fix(api): 修复查询接口参数错误
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;漂亮又标准！&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;如果你在使用 ​&lt;code&gt;Visual Studio Code&lt;/code&gt;，安装&lt;code&gt;Visual Studio Code Commitizen Support&lt;/code&gt;扩展后还可以直接在源代码管理中直接使用，非常方便 ​&lt;/strong&gt;​&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;最后的话&lt;/h2&gt;
&lt;p&gt;我不是专业搞规范的人，也不是在什么大厂搞了十年流程，我只是被提交信息搞得烦了，自己学了一下，觉得这个方案对我很有用，就想分享出来。&lt;/p&gt;
&lt;p&gt;你要是也有类似的烦恼，或者团队人不多但也希望能“稍微有点规范”，那就试试这个方案吧～&lt;/p&gt;
&lt;p&gt;当然啦，这种方式肯定不是唯一方案，已经很多大团队用得更深更自动化，我这个更像是入门轻量版，但已经能解决很多小烦恼了。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;🧡 希望对你有帮助&lt;/p&gt;</content:encoded><h:img src="/_astro/github.CfLLElkp.jpg"/><enclosure url="/_astro/github.CfLLElkp.jpg"/></item><item><title>一键解析加密账单：PHP实现微信支付宝自动化对账工具</title><link>https://hejunjie.life/blog/623bb7bf</link><guid isPermaLink="true">https://hejunjie.life/blog/623bb7bf</guid><description>这篇文章介绍了我如何用 PHP 编写工具，实现支付宝和微信账单的自动化解析与导入。通过 C 语言配合 libzip 多线程暴力破解加密压缩包，打通个人财务数据自动化的最后一环。文中提供完整开源项目地址，适合有同样需求的开发者参考使用</description><pubDate>Tue, 27 May 2025 12:24:41 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;“账单不难管，难的是每次都要手动点开压缩包，然后去找密码……”&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;作为一个软件开发者，我除了本职工作，也经常接些副业项目。加上日常各种零碎的支出，其实每天的消费记录都不少。&lt;/p&gt;
&lt;p&gt;因为家里有一台自己的服务器，我一直尝试把一些个人信息收集起来，比如智能设备的健康数据、电脑使用记录等等，再集中在一个小网站上做可视化统计。消费记录，自然是其中的重要一环。&lt;/p&gt;
&lt;p&gt;但在这整条“账单自动化”链路中，有一步始终特别烦人：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;每月下载账单 → 找密码解压 → 打开 CSV → 清洗数据 → 导入系统&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;更麻烦的是，压缩包的密码通过公众号/服务号发来，而账单本身发到邮箱，你得手动去翻邮件下载，再手动复制密码、解压、处理数据。&lt;/p&gt;
&lt;p&gt;虽然信息提取和入库我早已脚本化，但“下载 + 解压 + 找密码”这块，始终让我觉得冗余又机械。&lt;/p&gt;
&lt;p&gt;直到某天上厕所时突然想到：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;账单都发到邮箱了，我干嘛不让服务器自己监听，收到就自动处理？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;一旦这一步打通，剩下唯一的问题就是——&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;怎么让程序自己解开带密码的压缩包？&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;我的目标很简单&lt;/h2&gt;
&lt;p&gt;我那个展示统计信息的网站是 PHP 写的，那干脆就用 PHP 解决这整个账单处理流程。&lt;/p&gt;
&lt;p&gt;目标就四个字：&lt;strong&gt;全自动化&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;具体来说就是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;自动解析支付宝、微信账单的 zip 压缩包；&lt;/li&gt;
&lt;li&gt;无需手动输入密码，自动暴力破解；&lt;/li&gt;
&lt;li&gt;解压出内部 CSV 后自动提取数据；&lt;/li&gt;
&lt;li&gt;拆成 Composer 包，按需引入。&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;暴力但有效的解决方案&lt;/h2&gt;
&lt;p&gt;微信和支付宝的账单压缩包有个共同特点：&lt;strong&gt;密码是 6 位纯数字&lt;/strong&gt;（比如支付密码的后六位）。&lt;/p&gt;
&lt;p&gt;也就是说，总共只有 100 万种组合，从 &lt;code&gt;000000&lt;/code&gt;​ 到 &lt;code&gt;999999&lt;/code&gt;​。&lt;/p&gt;
&lt;p&gt;对电脑来说，这点穷举量根本不叫事。但如果用 PHP 来暴力破解……就有点像拿自行车拉货了。&lt;/p&gt;
&lt;p&gt;PHP 作为解释型语言，每行代码都得“边跑边翻译”，又要频繁创建变量、垃圾回收，性能开销很大。真让它跑完一轮，全核 CPU 干到 100%，可能也得跑个几分钟，体验非常拉跨。&lt;/p&gt;
&lt;p&gt;所以我干脆请出 C 语言，用多线程 &lt;code&gt;pthread&lt;/code&gt;​ 调用 &lt;code&gt;libzip&lt;/code&gt;​ 的底层 API 来解压：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;秒级完成密码破解；&lt;/li&gt;
&lt;li&gt;多线程并发不拖后腿；&lt;/li&gt;
&lt;li&gt;性能消耗低，不影响主机运行。&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;项目地址（已开源）&lt;/h2&gt;
&lt;p&gt;代码我已经开源，逻辑非常直白，欢迎参考：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;微信账单解析器&lt;br&gt;
🔗 &lt;a href=&quot;https://github.com/zxc7563598/php-wechat-bill-parser&quot;&gt;https://github.com/zxc7563598/php-wechat-bill-parser&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;支付宝账单解析器&lt;br&gt;
🔗 &lt;a href=&quot;https://github.com/zxc7563598/php-alipay-bill-parser&quot;&gt;https://github.com/zxc7563598/php-alipay-bill-parser&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;之所以拆成两个包，是因为它们的解压逻辑、编码格式、字段结构等略有差异。也考虑到有些人可能只需要其中一个平台，所以没必要整合成一个大包。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;最后&lt;/h2&gt;
&lt;p&gt;在写这个工具之前，我每个月都得花十几分钟：下载账单、找密码、解压、清洗、导入……虽然听起来不长，但真的是每次都烦，而且完全是重复劳动。&lt;/p&gt;
&lt;p&gt;现在整个流程已经完全自动化：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;服务器监听到账单邮件通知&lt;/li&gt;
&lt;li&gt;自动下载 zip 压缩包&lt;/li&gt;
&lt;li&gt;自动解压 + 数据解析&lt;/li&gt;
&lt;li&gt;存入数据库&lt;/li&gt;
&lt;li&gt;网页前端实时展示图表&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;我唯一需要做的，就是每月把账单导出发送到邮箱。其它的，服务器自动搞定。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;仓库地址就在上面，感兴趣的朋友欢迎 Star、一起来交流。如果你也曾为这些小事烦恼过，希望这个工具对你有帮助。也欢迎分享给身边有同样需求的人。&lt;/p&gt;</content:encoded><h:img src="/_astro/php.DoApYH8-.png"/><enclosure url="/_astro/php.DoApYH8-.png"/></item><item><title>B站直播积分商城</title><link>https://hejunjie.life/blog/b4f053ee</link><guid isPermaLink="true">https://hejunjie.life/blog/b4f053ee</guid><description>在这篇详细教程中，将教你如何从零开始部署哔哩哔哩直播机器人和积分商城。无论你是初学者还是开发者，都能轻松跟随步骤搭建自己的直播机器人，实现弹幕监控、自动答谢、定时广告等功能，并让观众通过消费获得积分选择礼物。提供从购买服务器到部署项目的完整指南，让你快速上手</description><pubDate>Thu, 15 May 2025 10:12:32 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;有感觉到文章很乱吗？虽然不知道你怎么想但反正我是有点感觉到了。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;不同时间出了好几个教程管理起来真的很麻烦。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;我把所有相关的内容放到了一个文档中：&lt;a href=&quot;/danmusuite&quot;&gt;点击前往&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;项目简介&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/zxc7563598/php-bilibili-danmu&quot;&gt;bilibili-danmu&lt;/a&gt;是一个集弹幕监控、礼物答谢、定时广告、关注感谢、自动回复等功能于一体的综合性工具。它内置了完整的积分商城系统，支持私有部署，无需审核限制。用户可通过签到或开通大航海等行为自动获取积分，并用于兑换礼物。&lt;/p&gt;
&lt;p&gt;这不仅让它超越了一般的弹幕机器人，更成为一个提升观众互动、增强直播粘性、助力主播变现的全方位解决方案。&lt;/p&gt;
&lt;p&gt;GitHub 仓库地址：&lt;a href=&quot;https://github.com/zxc7563598/php-bilibili-danmu&quot;&gt;点击进入&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;视频教学：&lt;a href=&quot;https://www.bilibili.com/video/BV12XJuzqE8M&quot;&gt;点击查看&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;核心功能&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;积分商城&lt;/strong&gt;：用户可通过每日签到或开通直播间大航海获取积分，兑换商城中的虚拟道具或实体礼品。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;直播间打卡签到&lt;/strong&gt;：用户每日完成直播间签到，可累计/连续记录签到天数，并获得相应积分，用于兑换积分商城内的各类商品。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PK 播报&lt;/strong&gt;：PK 对战开启前，系统将自动播报对手直播间成员活跃度及贡献榜单信息，可自定义播报内容。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;礼物答谢&lt;/strong&gt;：收到观众礼物时自动触发答谢功能，支持自定义答谢金额门槛和多条个性化答谢文案。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;定时广告&lt;/strong&gt;：定时发送预设内容至直播间，支持配置多条文案并智能随机轮播。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;进房欢迎&lt;/strong&gt;：用户进入直播间时自动欢迎，并支持配置多条差异化欢迎话术随机展示。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;感谢关注&lt;/strong&gt;：用户关注直播间时自动感谢，并支持配置多条差异化欢迎话术随机展示。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;感谢分享&lt;/strong&gt;：用户分享直播间时自动感谢，并支持配置多条差异化欢迎话术随机展示。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;自动回复&lt;/strong&gt;：当用户弹幕触发预设关键词时，系统将智能匹配并随机推送差异化回复内容，支持自定义多套回复方案。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;自动禁言&lt;/strong&gt;：基于自动回复功能，当触发自动回复规则时，可对违规用户执行临时禁言处罚，同时支持用户通过赠送指定价值的电池礼物来提前解除禁言状态。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;部署方案&lt;/h2&gt;
&lt;h3&gt;新手推荐方案：Docker 一键部署&lt;/h3&gt;
&lt;p&gt;可能不是最完美的方案，但它一定是对新手最友好的选择：一键配置，命令执行后静待完成，项目会自动拉取、配置并保持更新，全程无需额外操作。&lt;/p&gt;
&lt;p&gt;在你的服务器上执行：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;curl -fsSL https://bilibili-danmu-scripts.oss-cn-hongkong.aliyuncs.com/install-docker.sh | bash
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;没有开发基础的用户&lt;/strong&gt; 请确保您使用的是 &lt;a href=&quot;https://cn.aliyun.com&quot;&gt;阿里云&lt;/a&gt; 位于 &lt;strong&gt;香港&lt;/strong&gt; 地域的 &lt;strong&gt;Ubuntu 24.04 64 位&lt;/strong&gt; 版本的服务器。这是一套经过验证的环境，能最大程度减少部署问题&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;其他云服务商或系统版本未做兼容性测试，不排除出现问题的可能性。如果你遇到问题，可以来问我，我乐意帮忙，全当交个朋友。但不保证解决。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;一键部署脚本写得很简单，不依赖复杂逻辑，有动手能力的用户完全可以自行调整环境。完全不懂的朋友，建议按推荐环境来，别自找麻烦。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;不会购买服务器看下方「一些问题」中的「关于购买服务器」&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;购买服务器不知道如何使用的看下方「一些问题」中的「购买了服务器要如何使用」&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;手动部署方案&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;以下是手动部署项目的推荐流程，适合具备基本 PHP 环境搭建经验的用户。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;后台本体：&lt;a href=&quot;https://github.com/zxc7563598/php-bilibili-danmu&quot;&gt;Github 仓库&lt;/a&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;环境要求&lt;/strong&gt;：LNMP 环境，PHP 8.1+，需安装 Redis、Brotli、GD 扩展&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;项目结构&lt;/strong&gt;：标准 PHP 项目，依赖管理采用 Composer，符合现代开发规范。首次运行前请执行 &lt;code&gt;composer install&lt;/code&gt;​ 安装依赖。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;数据库迁移&lt;/strong&gt;：使用 Phinx 进行管理。请在完成 &lt;code&gt;.env&lt;/code&gt;​ 配置及依赖安装后，执行 &lt;code&gt;php vendor/bin/phinx migrate -e development&lt;/code&gt;​ 进行数据库结构初始化（需提前创建一个 UTF8MB4 编码的数据库）。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;运行方式&lt;/strong&gt;：基于 Webman 框架，启动流程与官方一致，详细说明可参考&lt;a href=&quot;https://www.workerman.net/doc/webman/others/nginx-proxy.html&quot;&gt;官方文档&lt;/a&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git clone https://github.com/zxc7563598/php-bilibili-danmu.git ./
cp .env.example .env
# 修改 .env 配置，参考 .env.example 填写
composer install
php vendor/bin/phinx migrate -e development
php start.php start -d
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;积分商城端：&lt;a href=&quot;https://github.com/zxc7563598/vue-bilibili-danmu-shop&quot;&gt;Github 仓库&lt;/a&gt;
后台管理端：&lt;a href=&quot;https://github.com/zxc7563598/vue-bilibili-danmu-admin&quot;&gt;Github 仓库&lt;/a&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;环境要求&lt;/strong&gt;：本地 NodeJs 环境&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;项目结构&lt;/strong&gt;：基于 Vite 构建的 Vue 标准项目，首次使用请先通过 &lt;code&gt;npm install&lt;/code&gt;​ 安装依赖。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;运行方式&lt;/strong&gt;：所有 npm 命令均保持默认配置，通过 &lt;code&gt;npm run dev&lt;/code&gt; 启动开发模式，通过 &lt;code&gt;npm run build&lt;/code&gt; 打包生产版本。配置文件通过 &lt;code&gt;.env&lt;/code&gt; 管理，可参考 &lt;code&gt;.env.example&lt;/code&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git clone 对应的仓库 ./
cp .env.example .env
# 修改 .env 配置，参考 .env.example 填写
npm install
npm run build
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;一些问题（持续更新）&lt;/h2&gt;
&lt;p&gt;这里整理了一些常见但零散的问题，集中统一解答。&lt;/p&gt;
&lt;p&gt;如果你对这些内容已有了解，完全可以跳过这一节，仓库代码都在上面，自己看、自己用，相信你们有能力解决大多数问题。&lt;/p&gt;
&lt;p&gt;但如果你是刚入门、对某些原理一头雾水的朋友，也欢迎从这里看看。我会尽量讲清楚“问题出在哪里”，以及“为什么要这么处理”，希望能帮你少走一点弯路。&lt;/p&gt;
&lt;h3&gt;关于购买服务器&lt;/h3&gt;
&lt;h4&gt;为什么要购买服务器？&lt;/h4&gt;
&lt;p&gt;这不是一个传统意义上的“机器人”项目。如果你只是需要一个弹幕助手，确实可以直接在自己的电脑上运行，直播时连上直播间，下播后关掉就行。&lt;/p&gt;
&lt;p&gt;但这个项目是一个积分商城，实质上就是你自己的一个网站。它需要 24 小时持续运行，并且对外开放，让观众随时都能访问和使用。所以，和普通机器人不同，它需要部署在一台能够长期在线、稳定对外服务的服务器上。&lt;/p&gt;
&lt;h4&gt;如何购买服务器？&lt;/h4&gt;
&lt;p&gt;前往 &lt;a href=&quot;https://ecs.console.aliyun.com/home&quot;&gt;阿里云云服务器 ECS 控制台&lt;/a&gt; → 点击「创建实例」 → 选择合适的配置进行购买。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://hejunjie-blog.oss-rg-china-mainland.aliyuncs.com/b06795f9/00004.png#pic_center&quot; alt=&quot;配置图，可按此图配置购买&quot;&gt;&lt;/p&gt;
&lt;p&gt;以下是推荐配置说明：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;地域&lt;/strong&gt;：请选择 &lt;strong&gt;中国香港&lt;/strong&gt;（具体原因详见下方章节 “为什么选择香港服务器”）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;配置&lt;/strong&gt;：最低建议选择 &lt;code&gt;2 vCPU + 2 GiB 内存&lt;/code&gt;​。计算型、共享型等都可，&lt;strong&gt;不推荐“突发性能实例”&lt;/strong&gt; ，这类机型在大多数时间会限制 CPU 性能，影响使用体验。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;系统镜像&lt;/strong&gt;：请选择 &lt;code&gt;Ubuntu 20.04 64位&lt;/code&gt;​，我所有的测试与部署都基于此版本，兼容性和稳定性最好。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;其他配置可根据个人需求自行选择，不过如果你是第一次使用，建议直接参考上图进行配置，省事也稳妥。&lt;/p&gt;
&lt;h3&gt;关于为什么要选择香港服务器&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;备案麻烦 &amp;#x26; 使用限制：&lt;/strong&gt;&lt;br&gt;
大陆地区的服务器对外提供互联网服务时，必须完成备案和一系列合规流程，流程繁琐，还会受到一定的监管。相比之下，香港属于中国，却&lt;strong&gt;不需要备案&lt;/strong&gt;，开箱即用、稳定省事。&lt;br&gt;
至于日本、韩国等境外服务器，由于&lt;strong&gt;科学上网问题&lt;/strong&gt;，连接经常不稳定，不推荐新手使用。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;代码托管在 GitHub：&lt;/strong&gt;&lt;br&gt;
如果你选择一键部署，服务器需要从 GitHub 拉取代码，而 GitHub 并不在国内。&lt;br&gt;
&lt;strong&gt;大陆服务器与个人一样也会受到网络限制&lt;/strong&gt;，可能导致部署失败。
&lt;blockquote&gt;
&lt;p&gt;小提示：如果你打算在自家的服务器、NAS 或虚拟机中部署，请务必先解决访问 GitHub 的问题。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;实用角度考虑：&lt;/strong&gt;&lt;br&gt;
其实用国内服务器也不是不能用，就是容易自找麻烦。&lt;br&gt;
阿里云香港的稳定性不错，部署项目之外还能顺便搭个梯子。虽然 ChatGPT 屏蔽了香港 IP，但&lt;strong&gt;看点其他内容、搭建加速服务还是很香的&lt;/strong&gt;，明码标价、速度稳定，视频都能流畅播放。
&lt;blockquote&gt;
&lt;p&gt;如果你不懂怎么搭梯子，欢迎私聊，我可以教你。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;购买了服务器要如何使用&lt;/h3&gt;
&lt;p&gt;可以通过 FinalShell 远程连接直播间：&lt;a href=&quot;https://www.hostbuf.com/t/988.html&quot;&gt;点击下载&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://hejunjie-blog.oss-rg-china-mainland.aliyuncs.com/b06795f9/00011.png#pic_center&quot; alt=&quot;如何连接服务器&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://hejunjie-blog.oss-rg-china-mainland.aliyuncs.com/b06795f9/00012.png#pic_center&quot; alt=&quot;连接完成后运行一键安装脚本即可&quot;&gt;&lt;/p&gt;
&lt;h3&gt;关于配置域名&lt;/h3&gt;
&lt;h4&gt;为什么需要域名？&lt;/h4&gt;
&lt;p&gt;没有域名也能访问你的网站，正常情况下就是通过 &lt;code&gt;http://你的服务器IP:7777&lt;/code&gt;​ 来访问后台管理，商城则是 &lt;code&gt;http://你的服务器IP:5177&lt;/code&gt;​。&lt;/p&gt;
&lt;p&gt;这当然可以用，如果你不介意链接不好看，也可以直接生成二维码发给观众扫码，访问是没有任何问题的。&lt;/p&gt;
&lt;p&gt;但从使用体验来说，IP 地址链接就像毛坯房一样：&lt;br&gt;
丑、不好记、不专业、不安全。&lt;/p&gt;
&lt;p&gt;就像百度的 IP 地址是 &lt;code&gt;http://39.156.70.46&lt;/code&gt;​，你能访问它没错，但肯定没人会去记这个地址。相比之下，&lt;code&gt;https://baidu.com&lt;/code&gt;​ 简洁、有辨识度、安全性也更高。&lt;/p&gt;
&lt;p&gt;所以，&lt;strong&gt;域名的意义不只是“能访问”，而是让你的项目看起来像个“成品”而不是“内测工具”&lt;/strong&gt; 。而且现在域名注册成本也不高，是值得的投入。&lt;/p&gt;
&lt;h4&gt;如何购买域名？&lt;/h4&gt;
&lt;p&gt;域名注册的平台有很多，比如 &lt;a href=&quot;https://www.godaddy.com&quot;&gt;GoDaddy&lt;/a&gt; 和 &lt;a href=&quot;https://dc.console.aliyun.com&quot;&gt;阿里云&lt;/a&gt; 都是业内比较成熟、可靠的选择。&lt;/p&gt;
&lt;p&gt;如果你已经在阿里云购买了服务器，那直接在阿里云顺手把域名也买了会更方便。购买流程不复杂：&lt;br&gt;
搜索你想要的域名，看看有没有被注册，没被注册就可以直接下单购买。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;注意：&lt;/strong&gt; 在阿里云购买域名时需要先完成&lt;strong&gt;实名认证&lt;/strong&gt;，流程不麻烦，按照提示提交资料即可。不需要备案，只是做个身份验证。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;另外提醒一下：&lt;br&gt;
如果你的服务器不是香港而是中国大陆地区的，那我通常&lt;strong&gt;不建议你单独买域名&lt;/strong&gt;。因为&lt;strong&gt;域名如果解析到大陆服务器，必须完成 ICP 备案&lt;/strong&gt;，这个流程需要向工信部提交材料，耗时大约 20 个工作日左右。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;而且如果你是个人备案，只能用于“博客类”网站，像这种积分商城类项目是无法备案的，必须使用企业主体才能通过。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4&gt;购买域名后如何使用&lt;/h4&gt;
&lt;p&gt;购买域名后，请前往 &lt;a href=&quot;https://dc.console.aliyun.com/#/domain-list/all&quot;&gt;阿里云域名列表&lt;/a&gt;：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;找到你购买的域名 → 点击“操作” → 选择“解析” → 进入添加记录页面&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;img src=&quot;https://hejunjie-blog.oss-rg-china-mainland.aliyuncs.com/b06795f9/00009.png#pic_center&quot; alt=&quot;添加记录页面&quot;&gt;&lt;/p&gt;
&lt;p&gt;在“添加记录”页面中，大部分设置（如记录类型、解析线路、TTL）保持默认即可，只需要关注以下两个字段：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;主机记录&lt;/strong&gt;：即你想绑定的子域名前缀。例如你的域名是 &lt;code&gt;hejunjie.life&lt;/code&gt;​，这里填写 &lt;code&gt;asd&lt;/code&gt;​，那最终用户访问的地址就是 &lt;code&gt;asd.hejunjie.life&lt;/code&gt;​&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;记录值&lt;/strong&gt;：填写你服务器的公网 IP 地址&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;你需要添加 &lt;strong&gt;两个解析记录&lt;/strong&gt; 指向你的服务器：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;一个用于访问 &lt;strong&gt;机器人后台&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;一个用于访问 &lt;strong&gt;积分商城&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;⚠️ 域名解析通常需要几分钟时间才能生效，建议等待 &lt;strong&gt;10 分钟左右&lt;/strong&gt; 确保解析成功。&lt;/p&gt;
&lt;p&gt;✅ 解析成功后，登陆服务器配置域名和 SSL 证书即可&lt;/p&gt;
&lt;h4&gt;如何配置服务器域名和 SSL 证书&lt;/h4&gt;
&lt;p&gt;解析完成后，登录你的服务器，执行以下命令完成自动配置（包括 Nginx 和 SSL）：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;curl -fsSL https://bilibili-danmu-scripts.oss-cn-hongkong.aliyuncs.com/setup-nginx-ssl.sh | bash -s 后台地址 积分商城地址 你的邮箱
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;“后台地址”和“积分商城地址”就是你刚才解析的域名，例如：&lt;code&gt;asd.hejunjie.life&lt;/code&gt;​&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;执行成功后，进入机器人后台控制台 → 打开「系统配置」：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://hejunjie-blog.oss-rg-china-mainland.aliyuncs.com/b06795f9/00010.png#pic_center&quot; alt=&quot;机器人控制台系统配置页面&quot;&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;修改 &lt;code&gt;项目地址&lt;/code&gt;​ 为：&lt;code&gt;https://后台的地址&lt;/code&gt;​&lt;/li&gt;
&lt;li&gt;修改 &lt;code&gt;商城地址&lt;/code&gt;​ 为：&lt;code&gt;https://积分商城的地址&lt;/code&gt;​&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;例如你填的是 &lt;code&gt;asd.hejunjie.life&lt;/code&gt;​，那就改为：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;​&lt;code&gt;https://asd.hejunjie.life&lt;/code&gt;​&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;配置完成后，你就可以通过浏览器访问：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;控制台后台：&lt;code&gt;https://后台的地址&lt;/code&gt;​&lt;/li&gt;
&lt;li&gt;用户商城：&lt;code&gt;https://积分商城的地址&lt;/code&gt;​&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;关于独立后台&lt;/h3&gt;
&lt;p&gt;仓库地址：&lt;a href=&quot;https://github.com/zxc7563598/vue-bilibili-danmu-admin&quot;&gt;点击查看&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;现在的机器人实际上已经内置了一个原生后台，&lt;strong&gt;已可直接使用，此项目非刚需&lt;/strong&gt;。本项目的目标是为追求更强大后台功能的用户提供一个更完善的替代方案。&lt;/p&gt;
&lt;p&gt;由于在推荐服务器配置时出于成本考虑，建议使用较低配置的服务器（如 &lt;strong&gt;2 核 2G&lt;/strong&gt;）。这种配置在实际运行项目时并不会有明显问题，但如果尝试在服务器上直接构建本项目，性能压力会非常大，构建过程可能会失败甚至卡死。&lt;/p&gt;
&lt;p&gt;因此，我们将前端后台代码单独抽离到此仓库，&lt;strong&gt;建议用户在本地完成构建后再上传到服务器&lt;/strong&gt;。这并不是一项复杂的操作，欢迎动手尝试，如有困难也可以联系作者获取帮助。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;部署方式&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# 1. 下载项目
git clone https://github.com/zxc7563598/vue-bilibili-danmu-admin.git
cd vue-bilibili-danmu-admin


# 2. 安装 Node.js
# 请前往官网下载安装适合你系统的版本：https://nodejs.org/zh-cn

# 3. 配置环境变量：复制 `.env.example` 为 `.env`
cp .env.example .env
# 根据实际情况填写请求地址、密钥等信息，具体信息可在机器人控制台系统配置中查看。

# 4. 安装依赖 &amp;#x26; 构建项目
npm install
npm run build

# 执行后将生成 `dist/` 目录，即为最终可部署版本，将此目录上传到 `/opt/bilibili-robots/php/public` 目录中，重新访问机器人控制台，即可自动切换到新版后台。

# 使用新版后台的过程中，删除 `/opt/bilibili-robots/php/public/dist` 目录即可停用新版后台、切换回老版本后台
&lt;/code&gt;&lt;/pre&gt;</content:encoded><h:img src="/_astro/bilibili.p76c9pFG.jpg"/><enclosure url="/_astro/bilibili.p76c9pFG.jpg"/></item><item><title>别再被“AI工作流”忽悠了</title><link>https://hejunjie.life/blog/69779d56</link><guid isPermaLink="true">https://hejunjie.life/blog/69779d56</guid><description>你不需要智能体也能打造AI工作流！本文从一个被短视频吹神的“AI智能体”案例出发，讲清楚所谓AI工作流其实就是提示词加调用，避坑平台套路，从简单翻译器起步理解AI自动化的本质</description><pubDate>Mon, 12 May 2025 02:25:32 GMT</pubDate><content:encoded>&lt;h2&gt;📌 前言：我为什么写这篇文章？&lt;/h2&gt;
&lt;p&gt;先说清楚，我&lt;strong&gt;并不是否定 AI 工作流、Agent 架构、Agentic Workflow 这些东西本身的价值&lt;/strong&gt;。它们确实是当前大模型（LLM）生态里非常重要的一环，背后有严肃的研究、系统性的设计、值得深入探讨的范式迁移方向。&lt;/p&gt;
&lt;p&gt;如果你真要构建复杂的多步协同、多模态融合、具备推理能力的“智能体系统”，你是绕不过去的。&lt;/p&gt;
&lt;p&gt;但问题是：&lt;strong&gt;普通人要的真的是这些吗？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;我写这篇文章，是因为我女朋友最近在刷各种 AI 工作流短视频，动不动就是“用 AI 做全自动副业”、“教你三分钟打造 AI 智能体”、“不会代码也能做赚钱系统”这种标题，看得津津有味。那些视频讲得天花乱坠，晒代码、画流程、用术语说话，什么“多智能体协同”“上下文协商”，听起来跟做科研似的。&lt;/p&gt;
&lt;p&gt;结果点进去看，所谓的“AI 系统”，无非就是套几个提示词，接个 Coze 表单，甚至最终目的还是为了引流、卖课、收徒，搞得跟玄学一样。&lt;/p&gt;
&lt;p&gt;说实话，这让我感到无语。&lt;/p&gt;
&lt;p&gt;所以我想写下这篇文章做个“降温”：&lt;strong&gt;别被“AI 工作流”这个词吓住了，它其实没那么神秘。很多你以为的系统，其实只需要两段提示词就能跑通。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;你不需要一上来就搞什么“Agentic Architecture”，就好像&lt;strong&gt;你去菜市场买菜并不需要懂微积分一样&lt;/strong&gt;。咱们先搞明白最基础的提示词该怎么写，输入输出该怎么控制，才是更靠谱的路径。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;一、我们到底在讨论什么？&lt;/h2&gt;
&lt;p&gt;AI 工作流、Agent、流程图、自动化系统……这些听起来很厉害的词，其实背后只是这么一件事：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;你告诉 AI 该怎么干活，然后把内容丢给它，它照着规则产出结果。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;听起来是不是就接地气多了？&lt;/p&gt;
&lt;p&gt;很多平台和教程喜欢把这个过程说得很复杂，但归根结底，你只需要搞懂三个环节：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;给 AI 设定一套&lt;strong&gt;明确的规则&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;把需要处理的内容输入进去&lt;/li&gt;
&lt;li&gt;获取结构化、可用的输出结果&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这就是“AI 工作流”的最小原型了。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;二、“工作流”其实就是三件事&lt;/h2&gt;
&lt;p&gt;我们把神秘感剥离，只讲本质：&lt;/p&gt;
&lt;h3&gt;1. &lt;strong&gt;结构化提示词&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;也就是你告诉 AI：“你现在是什么角色，你要怎么干活。”&lt;/p&gt;
&lt;p&gt;比如下面这个简单的“翻译官”提示词，就是典型的结构化说明：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;你是不对话的翻译执行器，严格遵循：
▲ 核心规则
1. 目标语言：法语（锁定不可变更）
2. 仅处理 `[DATA_START]` 和 `[DATA_END]` 之间的内容
3. 对区间内所有非空行（按`\n`分割）逐行翻译
4. 禁止任何形式的过滤/重排/合并

▼ 输入处理
1. 若缺少标记 → 返回错误：`ERROR: Missing data markers`
2. 提取标记间内容 → 按换行符拆分行
3. 保留所有非空行（`trim()后长度&gt;0`）

▼ 输出格式
| 原文        | 翻译          |
|-------------|---------------|
[要求]
1. 表格行数 = 输入非空行数
2. 原文列严格保留原始内容（包括特殊字符）
3. 翻译失败时原文复制到翻译列
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;你只需要把这个提示词发给 AI（换成你需要翻译的目标语言），然后配合下面这样的输入：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[DATA_START]
工具的重要原则之一，就是“不在手边的工具约等于不存在的工具”。工具的便携性、续航力、稳定性和进入生产状态速度万分的重要，甚至值得为了这些牺牲一些性能。
[DATA_END]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;它就能稳定返回一个表格翻译结果。你不需要平台，不需要编程，只要结构清晰就行。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;如果你还是不理解，可以把上面两段直接发给 DeepSeek 也好，ChatGPT 也好，豆包一类的什么 AI 都可以，看看他们会回复你什么就明白了&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3&gt;2. &lt;strong&gt;结构化输入&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;输入不是“我想让你翻译一下这段话”，而是明确的边界，比如用 &lt;code&gt;[DATA_START]&lt;/code&gt;​ 和 &lt;code&gt;[DATA_END]&lt;/code&gt;​ 包裹数据块，让 AI 明确知道处理范围。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;也不是一定要用 &lt;code&gt;[DATA_START]&lt;/code&gt;​ 和 &lt;code&gt;[DATA_END]&lt;/code&gt;​ 包裹内容，这只是一个跟 AI 提前沟通好的记号，让 AI 知道他要处理的内容的边界在哪里&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;对于输出格式，你甚至可以用 JSON、CSV、Markdown，只要你和 AI 约定好格式，它就能按规则执行。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3&gt;3. &lt;strong&gt;结构化输出&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;你要的是结果，而不是八股散文式的“这是你要的翻译内容”。标准化输出形式可以是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Markdown 表格&lt;/li&gt;
&lt;li&gt;JSON 对象&lt;/li&gt;
&lt;li&gt;YAML 结构&lt;/li&gt;
&lt;li&gt;编程代码块&lt;/li&gt;
&lt;li&gt;文本清单&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;一旦结构稳定，你甚至可以写个小脚本自动提取和处理这些输出，形成真正的“自动流程”。&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;三、为什么我不建议一开始就用平台工具？&lt;/h2&gt;
&lt;p&gt;这点我要说得明确点：不是平台不好，而是你用它的&lt;strong&gt;时机不对&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;比如 Coze 这种平台确实强大、门槛低、拖拖拉拉就能出活，但问题是——&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;如果你还没理解“AI 对话怎么设计”、“提示词怎么控制”、“输出怎么验证”，你一上来就玩平台，&lt;strong&gt;只会学会点按钮，不会理解原理。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;❌ 新人常见的问题包括：&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;上下文污染：平台自动携带上下文，很容易影响下一次输出&lt;/li&gt;
&lt;li&gt;调试困难：流程崩了你根本不知道是哪步的问题&lt;/li&gt;
&lt;li&gt;提示词失控：平台可能中途修改了提示词或隐含系统消息&lt;/li&gt;
&lt;li&gt;黑盒操作：你并不清楚自己在和 AI 说什么&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;✅ 所以我更推荐：&lt;/h3&gt;
&lt;p&gt;在能力允许范围内，&lt;strong&gt;你完全可以用 Python（或 Go 等）写个小脚本&lt;/strong&gt;，来搭建你自己的工作流系统，优点是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;完全可控&lt;/strong&gt;：每次请求都只有提示词 + 输入，摒弃上下文&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;逻辑清晰&lt;/strong&gt;：你知道每一步是怎么处理的&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;可重复运行&lt;/strong&gt;：没有“AI 今天心情不好”的不确定性&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;后续扩展方便&lt;/strong&gt;：可以对接数据库、接口、文件系统等&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;以 DeepSeek 为例，各家 AI 平台都已经有很完善的文档，几行代码就可以进行调用：&lt;a href=&quot;https://api-docs.deepseek.com/zh-cn/&quot;&gt;DeepSeek 调用文档&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;即使你无法理解，根据文档与 AI 对话沟通，让他给你写一个小脚本来调用也并不是不可能实现的任务&lt;/p&gt;
&lt;p&gt;当然，如果你本身并不懂编程，或者你要处理的是视频转写、语音识别、多模型调用等复杂任务，那像 Coze 这样的平台也完全是可选项，&lt;strong&gt;关键在于：你要知道它适合处理什么场景，而不是一上来就全靠它。&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;四、我主张的，是“先打铁，再上系统”&lt;/h2&gt;
&lt;p&gt;我始终认为，一个好的 AI 工作流应该是&lt;strong&gt;从低投入、小试验、结构清晰、人工驱动开始的&lt;/strong&gt;，不是从工具开始，而是从理解开始：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;在跟 AI 的聊天对话框里，手动写提示词，把角色、规则、边界写清楚&lt;/li&gt;
&lt;li&gt;在跟 AI 的聊天对话框里手动粘贴输入你要处理的问题，测试稳定性&lt;/li&gt;
&lt;li&gt;尝试标准化输出，便于自动读取或人眼识别&lt;/li&gt;
&lt;li&gt;一步一步收敛结构、简化异常、优化体验&lt;/li&gt;
&lt;li&gt;最后再用代码/平台“提升效率”，而不是一上来“图省事”&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这才是最具性价比的方式。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;五、AI 工作流，不神秘，只要你说得明白&lt;/h2&gt;
&lt;p&gt;AI 不是魔法，它只是一个擅长模仿和理解模式的语言系统。你给它规则，它照规则处理；你写得含糊，它就输出不稳。&lt;/p&gt;
&lt;p&gt;什么“智能体”“多轮交互”都没那么神秘，真正的问题在于：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;你有没有能力把事情讲清楚。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;讲清楚角色，讲清楚输入，讲清楚你想要的结果，然后告诉 AI：“请照做。”&lt;/p&gt;
&lt;p&gt;这才是真正的“工作流”。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;🧩 总结一下：&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;不要一开始就被平台和术语吓住，AI 工作流其实就是“规范提示词+格式化输入+结构化输出”&lt;/li&gt;
&lt;li&gt;提示词才是灵魂，平台只是工具&lt;/li&gt;
&lt;li&gt;会写一个稳定提示词的你，胜过不懂原理的“智能体搭建者”&lt;/li&gt;
&lt;li&gt;想省力可以用平台，想掌控可以写代码，&lt;strong&gt;都没错，关键是你知道自己在做什么&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;p&gt;希望这篇文章能帮你对「AI 工作流」去魅，也欢迎你把它转给还在“平台焦虑”或者“技术迷雾”中的朋友。&lt;/p&gt;</content:encoded><h:img src="/_astro/ai.D4QuoLDn.png"/><enclosure url="/_astro/ai.D4QuoLDn.png"/></item><item><title>判断逻辑越写越乱，我干脆做了个自己的规则引擎</title><link>https://hejunjie.life/blog/da4e917a</link><guid isPermaLink="true">https://hejunjie.life/blog/da4e917a</guid><description>这篇文章介绍了一个轻量、易用的 PHP 规则引擎。它旨在帮助开发者从混乱的 if/else 中解放出来，以结构化的方式处理多条件判断。适用于业务规则、数据校验等场景，支持规则扩展、组合判断</description><pubDate>Wed, 30 Apr 2025 01:13:17 GMT</pubDate><content:encoded>&lt;p&gt;不知道你有没有这种感觉：一个业务功能看起来很简单，但判断条件却一大堆。&lt;br&gt;
什么用户状态、配置项、商品属性、会员等级……&lt;br&gt;
一大堆 &lt;code&gt;if&lt;/code&gt;​ / &lt;code&gt;else&lt;/code&gt;​ 交织在一起，越写越乱，稍微改一个逻辑就要担心影响其他地方。&lt;/p&gt;
&lt;p&gt;我之前就遇到这样的情况，一开始还能忍，后来干脆决定：&lt;strong&gt;不如自己写一个简单的规则引擎，专门用来处理这些组合判断。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;于是就有了这个项目：&lt;a href=&quot;https://github.com/zxc7563598/php-simple-rule-engine&quot;&gt;hejunjie/simple-rule-engine&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;🚀 这个规则引擎能干嘛？&lt;/h2&gt;
&lt;p&gt;一句话总结：&lt;br&gt;
&lt;strong&gt;这是一个轻量、易用的 PHP 规则引擎，支持多条件组合、动态规则执行，适合业务规则判断、数据校验等场景。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;适合用在你项目中的这些地方：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;复杂业务的多条件判断（比如用户是否满足某个活动要求）&lt;/li&gt;
&lt;li&gt;数据入库前的规则校验&lt;/li&gt;
&lt;li&gt;自定义逻辑的配置化、结构化处理&lt;/li&gt;
&lt;li&gt;写得一手 if 地狱，想抽出来整整齐齐 😅&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;🌟 为什么要做它？&lt;/h2&gt;
&lt;p&gt;在实际业务中，很多业务判断逻辑其实都可以归纳为：“一堆字段 + 一些规则 + 多个条件组合”。&lt;/p&gt;
&lt;p&gt;原本我们可能是这么写的：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;if (
    $user[&apos;status&apos;] === &apos;active&apos; &amp;#x26;&amp;#x26;
    $user[&apos;age&apos;] &gt;= 18 &amp;#x26;&amp;#x26;
    in_array($user[&apos;role&apos;], [&apos;admin&apos;, &apos;editor&apos;])
) {
    // ...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;现在可以这样：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;// 定义规则
$rules = [
    new Rule(&apos;age&apos;, &apos;&gt;=&apos;, 18, &apos;年龄必须大于等于18岁&apos;),
    new Rule(&apos;status&apos;, &apos;==&apos;, &apos;active&apos;, &apos;状态必须为active&apos;),
    new Rule(&apos;role&apos;, &apos;in&apos;, [&apos;admin&apos;, &apos;editor&apos;], &apos;角色需拥有权限&apos;),
];
// 评估结果
$result = Engine::evaluate($rules, $user, &apos;AND&apos;); // 返回 true 或 false

// 获取详细评估信息(用于获取每条规则的执行情况)
$details = Engine::evaluateWithDetails($rules, $user);
/*
返回示例：
[
    [&apos;description&apos; =&gt; &apos;年龄必须大于等于18岁&apos;, &apos;passed&apos; =&gt; true],
    [&apos;description&apos; =&gt; &apos;状态必须为active&apos;, &apos;passed&apos; =&gt; true],
    [&apos;description&apos; =&gt; &apos;角色需拥有权限&apos;, &apos;passed&apos; =&gt; true]
]
*/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;是不是整洁多了？而且如果你把规则放数据库，就能实现“业务判断配置化”了。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;🧩 项目特点&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;✅ &lt;strong&gt;轻量易用&lt;/strong&gt;：无依赖，无框架限制，简单几行就能用&lt;/li&gt;
&lt;li&gt;🔌 &lt;strong&gt;工厂注册机制&lt;/strong&gt;：你可以自己写新的操作符（Operator）注册进来&lt;/li&gt;
&lt;li&gt;📦 &lt;strong&gt;内置常用操作符&lt;/strong&gt;：多数常用操作符都支持（可见文章末尾操作符支持列表）&lt;/li&gt;
&lt;li&gt;🧠 &lt;strong&gt;可组合、多条件支持&lt;/strong&gt;：支持 AND / OR 关系组合，扩展多套规则逻辑很方便&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;📦 安装方法&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;composer require hejunjie/simple-rule-engine
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;🛠️ 示例代码&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;use Hejunjie\SimpleRuleEngine\Rule;
use Hejunjie\SimpleRuleEngine\Engine;

// 定义规则
$rules = [
    new Rule(&apos;age&apos;, &apos;&gt;=&apos;, 18, &apos;年龄必须大于等于18岁&apos;),
    new Rule(&apos;status&apos;, &apos;==&apos;, &apos;active&apos;, &apos;状态必须为active&apos;),
    new Rule(&apos;role&apos;, &apos;in&apos;, [&apos;admin&apos;, &apos;editor&apos;], &apos;角色需拥有权限&apos;),
];

$data = [&apos;age&apos; =&gt; 20, &apos;country&apos; =&gt; &apos;China&apos;];

// 简单判断是否通过全部规则
if (Engine::evaluate($rules, $data, &apos;AND&apos;)) {
    echo &apos;符合规则&apos;;
}

// 获取每一条规则是否通过的详情
foreach (Engine::evaluateWithDetails($rules, $data) as $detail) {
    echo $detail[&apos;description&apos;] . &apos;：&apos; . ($detail[&apos;passed&apos;] ? &apos;✅ 通过&apos; : &apos;❌ 未通过&apos;) . PHP_EOL;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;🔌 自定义操作符&lt;/h2&gt;
&lt;p&gt;你可以自由的去实现自己的判断逻辑，指定一个操作符，并自由的插入到你的规则中&lt;/p&gt;
&lt;p&gt;仅需要实现 &lt;code&gt;OperatorInterface&lt;/code&gt;​ 接口，并通过 &lt;code&gt;OperatorFactory&lt;/code&gt;​ 注册即可：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;use Hejunjie\SimpleRuleEngine\Interface\OperatorInterface;
use Hejunjie\SimpleRuleEngine\OperatorFactory;

class CustomizeOperator implements OperatorInterface
{
    /**
     * 评估方法
     *
     * @param mixed $fieldValue 用户输入数据
     * @param mixed $ruleValue 对比数据
     *
     * @return bool
     */
    public function evaluate(mixed $fieldValue, mixed $ruleValue): bool
    {
        // TODO: 实现判断逻辑
    }

    /**
     * 操作符名称
     *
     * @return string
     */
    public function name(): string
    {
        return &apos;customize&apos;;
    }
}

// 注册自定义操作符 customize
$factory = OperatorFactory::getInstance();
$factory-&gt;register(new CustomizeOperator());

// 可以在定义规则时使用 customize
$rules = [
    new Rule(&apos;field&apos;, &apos;customize&apos;, &apos;value&apos;, &apos;自定义规则描述&apos;),
    ...
    ...
];

// Engine::evaluate($rules, $data, &apos;AND&apos;)
// Engine::evaluateWithDetails($rules, $data)
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;🧩 内置操作符列表&lt;/h2&gt;
&lt;p&gt;| 操作符           | 描述             | 额外说明                         |
| ---------------- | ---------------- | -------------------------------- |
| ​&lt;code&gt;==&lt;/code&gt;​           | 等于             | 无                               |
| ​&lt;code&gt;!=&lt;/code&gt;​           | 不等于           | 无                               |
| ​&lt;code&gt;&gt;&lt;/code&gt;​            | 大于             | 无                               |
| ​&lt;code&gt;&gt;=&lt;/code&gt;​           | 大于等于         | 无                               |
| ​&lt;code&gt;&amp;#x3C;&lt;/code&gt;​            | 小于             | 无                               |
| ​&lt;code&gt;&amp;#x3C;=&lt;/code&gt;​           | 小于等于         | 无                               |
| ​&lt;code&gt;in&lt;/code&gt;​           | 包含于集合中     | 数组：[内容 1,内容 2,...]        |
| ​&lt;code&gt;not_in&lt;/code&gt;​       | 不包含于集合中   | 数组：[内容 1,内容 2,...]        |
| ​&lt;code&gt;contains&lt;/code&gt;​     | 包含字符串       | 无                               |
| ​&lt;code&gt;not_contains&lt;/code&gt;​ | 不包含字符串     | 无                               |
| ​&lt;code&gt;start_swith&lt;/code&gt;​  | 以指定字符串开头 | 无                               |
| ​&lt;code&gt;end_swith&lt;/code&gt;​    | 以指定字符串结尾 | 无                               |
| ​&lt;code&gt;between&lt;/code&gt;​      | 在指定范围内     | 数组：[最大值,最小值]            |
| ​&lt;code&gt;not_between&lt;/code&gt;​  | 不在指定范围内   | 数组：[最大值,最小值]            |
| ​&lt;code&gt;before_date&lt;/code&gt;​  | 日期早于         | 任意常规日期格式，包括时间戳均可 |
| ​&lt;code&gt;after_date&lt;/code&gt;​   | 日期晚于         | 任意常规日期格式，包括时间戳均可 |
| ​&lt;code&gt;date_equal&lt;/code&gt;​   | 日期相等         | 任意常规日期格式，包括时间戳均可 |&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;🤔 总结一下&lt;/h2&gt;
&lt;p&gt;这个规则引擎不是为了解决多么高级的技术难题，它只是一个&lt;strong&gt;更优雅的解决方式&lt;/strong&gt;。&lt;br&gt;
如果你也遇到过类似的 if/else 困扰，希望这个小工具能帮上你一点忙。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;欢迎 Star、Issue、PR，一起完善它 🙌&lt;br&gt;
如果你觉得有帮助，点个赞我会更有动力更新下去～&lt;/p&gt;</content:encoded><h:img src="/_astro/php.DoApYH8-.png"/><enclosure url="/_astro/php.DoApYH8-.png"/></item><item><title>顺手写了个地址解析小工具，支持在线用，也能接 PHP 项目里</title><link>https://hejunjie.life/blog/af605130</link><guid isPermaLink="true">https://hejunjie.life/blog/af605130</guid><description>一个用 PHP 编写的中国收货地址解析工具，支持姓名、手机号、省市区、详细地址等自动拆分，开箱即用。提供 Composer 包及在线演示，开发者和非技术用户都适用</description><pubDate>Tue, 22 Apr 2025 03:39:49 GMT</pubDate><content:encoded>&lt;p&gt;之前做项目的时候，经常遇到这种情况：用户填的收货地址是一整段话，像这样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;张三 13512345678 重庆市攀枝花市东区机场路88号 410123199001011234 邮编100000
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;人眼一看还行，但要让程序去拆成「姓名」「手机号」「省市区」「详细地址」这些字段，还是挺折磨的。&lt;/p&gt;
&lt;p&gt;所以我就自己写了一个小工具，现在开源出来了，分享给大家用。支持直接在网页上粘贴地址自动解析，也可以安装进 PHP 项目里用。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;✅ 不懂技术也能用：在线演示地址&lt;/h2&gt;
&lt;p&gt;如果你只是想把一批收货地址快速拆成结构化格式，比如帮客服导入表格、整理用户地址，那你直接用这个就行：&lt;/p&gt;
&lt;p&gt;👉 在线体验地址：&lt;a href=&quot;https://tools.hejunjie.life/#/external/address-parser&quot;&gt;点击立即使用&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;打开后，把地址粘进去，它会自动给你拆出来：&lt;/p&gt;
&lt;p&gt;| 字段     | 示例值               |
| -------- | -------------------- |
| 姓名     | 张三                 |
| 手机号   | 13512345678          |
| 身份证号 | 410123199001011234   |
| 省市区   | 四川省 攀枝花市 东区 |
| 详细地址 | 机场路 88 号         |
| 邮编     | 100000               |&lt;/p&gt;
&lt;p&gt;操作很简单，不用注册、不用装软件，直接用。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;🛠 这个工具怎么来的？&lt;/h2&gt;
&lt;p&gt;也没啥“市场需求分析”那种大词，其实就是我自己做系统的时候经常遇到类似的问题。&lt;/p&gt;
&lt;p&gt;尤其是后台管理系统，导入收货地址的时候，用户填的内容各种格式都有：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;用逗号分隔的&lt;/li&gt;
&lt;li&gt;什么标点都没有的&lt;/li&gt;
&lt;li&gt;中英文夹杂的&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;自己去写正则一个个拆，太麻烦，而且还不稳定。所以我就打算干脆写个通用一点的解析器，干干净净地输出结构化结果，自己用起来省事，也能给有类似需求的朋友用。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;🔍 它能干啥？&lt;/h2&gt;
&lt;p&gt;一句话总结：&lt;strong&gt;把一段收货地址文本，拆成结构化的字段&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;use Hejunjie\AddressParser\AddressParser;

$raw = &apos;张三，13512345678,410123199001011234 重庆攀枝花市东区机场路88号 邮编100000&apos;;

$parsed = AddressParser::parse($raw);

print_r($parsed);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;输出结果是：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;[
    &apos;name&apos; =&gt; &apos;张三&apos;,
    &apos;mobile&apos; =&gt; &apos;13512345678&apos;,
    &apos;idn&apos; =&gt; &apos;410123199001011234&apos;,
    &apos;postcode&apos; =&gt; &apos;100000&apos;,
    &apos;province&apos; =&gt; &apos;四川省&apos;,
    &apos;city&apos; =&gt; &apos;攀枝花市&apos;,
    &apos;area&apos; =&gt; &apos;东区&apos;,
    &apos;detail&apos; =&gt; &apos;机场路88号&apos;
]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;你可以拿这些字段去存数据库，或者回填表单都行。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;🧠 技术思路简单聊聊（给懂 PHP 的朋友）&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;手机号、身份证号、邮编识别&lt;/strong&gt;：用正则表达式直接提取&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;省市区识别&lt;/strong&gt;：内置了一份完整的中国行政区划数据，然后根据关键词匹配 + 模糊判断&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;姓名 vs 地址怎么分？&lt;/strong&gt; ：写了点简单的规则，比如常见的人名长度、位置优先级啥的&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;整个项目是&lt;strong&gt;纯 PHP 实现&lt;/strong&gt;的，没依赖 Laravel、ThinkPHP、Symfony 什么的，也不用数据库，装了就能用，&lt;strong&gt;适合绝大多数项目接入&lt;/strong&gt;。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;📦 怎么安装 / 用它？&lt;/h2&gt;
&lt;p&gt;如果你是开发者，那用 Composer 安装就行：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;composer require hejunjie/address-parser
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后直接调用静态方法：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;use Hejunjie\AddressParser\AddressParser;

$parsed = AddressParser::parse(&apos;张三 北京市朝阳区xxx小区 13512345678&apos;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;它会返回一个包含 &lt;code&gt;name&lt;/code&gt;​、&lt;code&gt;mobile&lt;/code&gt;​、&lt;code&gt;province&lt;/code&gt;​、&lt;code&gt;city&lt;/code&gt;​、&lt;code&gt;area&lt;/code&gt;​、&lt;code&gt;detail&lt;/code&gt;​ 等字段的数组。&lt;/p&gt;
&lt;p&gt;GitHub 项目地址在这里，欢迎 Star、Fork、提 Issue： 👉 &lt;a href=&quot;https://github.com/zxc7563598/php-address-parser&quot;&gt;zxc7563598/php-address-parser&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;🧩 最后：&lt;/h2&gt;
&lt;p&gt;这个工具本来是我做项目时的一个小需求，后来觉得也许别人也会用得上，就整理了下发出来。&lt;/p&gt;
&lt;p&gt;你是技术人员可以直接接入项目； 你不是技术人员，也可以直接用我做好的在线演示页面； 你有兴趣研究实现方式，那源码也完全开放了。&lt;/p&gt;
&lt;p&gt;总之，有用你就拿去用就行，简单实用比啥都强。&lt;/p&gt;</content:encoded><h:img src="/_astro/php.DoApYH8-.png"/><enclosure url="/_astro/php.DoApYH8-.png"/></item><item><title>MySQL 备份 Shell 脚本：支持远程同步与阿里云 OSS 备份</title><link>https://hejunjie.life/blog/758d8d3</link><guid isPermaLink="true">https://hejunjie.life/blog/758d8d3</guid><description>一款自动化 MySQL 备份 Shell 脚本，支持本地存储、远程服务器同步（SSH+rsync）、阿里云 OSS 备份，并自动清理过期备份。适用于数据库管理员和开发者，帮助确保数据安全</description><pubDate>Mon, 31 Mar 2025 05:31:27 GMT</pubDate><content:encoded>&lt;p&gt;之前我写过一个临时的 MySQL 备份脚本，主要是为了应急使用，功能比较简单。现在有时间了，我重新整理了一下，让它不仅能自动备份数据库，还支持远程服务器同步和上传到阿里云 OSS，这样即使本地备份丢失，数据也不会完全丢失。&lt;/p&gt;
&lt;p&gt;现在，这个脚本已经发布到 GitHub，地址在这里：&lt;br&gt;
👉 &lt;strong&gt;&lt;a href=&quot;https://github.com/zxc7563598/mysql-backup-shell&quot;&gt;GitHub 仓库 - mysql-backup-shell&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;这个脚本做了什么？&lt;/h2&gt;
&lt;p&gt;这个脚本的核心功能包括：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;自动备份 MySQL 数据库&lt;/strong&gt;，每天定时运行（可通过 &lt;code&gt;crontab&lt;/code&gt;​ 设置）。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;本地存储&lt;/strong&gt;：按日期分类存放备份文件，并自动删除过期备份，避免磁盘占满。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;远程服务器同步&lt;/strong&gt;：支持通过 &lt;code&gt;rsync&lt;/code&gt;​ 传输备份到另一台服务器，确保数据冗余。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;阿里云 OSS 备份&lt;/strong&gt;：可以将备份文件上传到阿里云对象存储，进一步增强安全性。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;自动清理过期备份&lt;/strong&gt;：定期清理本地、远程和 OSS 上的旧备份，减少存储成本。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;你可以在 &lt;code&gt;mysql_backup.sh&lt;/code&gt;​ 中修改配置，根据自己的需求调整备份策略。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;如何使用？&lt;/h2&gt;
&lt;h3&gt;1. 下载脚本&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git clone https://github.com/zxc7563598/mysql-backup-shell.git
cd mysql-backup-shell
chmod +x mysql_backup.sh
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后修改 &lt;code&gt;mysql_backup.sh&lt;/code&gt;​ 里的参数，确保 &lt;code&gt;MySQL&lt;/code&gt;​ 连接信息正确。&lt;/p&gt;
&lt;h3&gt;2. 设置定时任务&lt;/h3&gt;
&lt;p&gt;执行 &lt;code&gt;crontab -e&lt;/code&gt;​，然后添加：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;0 3 * * * /path/to/mysql_backup.sh
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样每天凌晨 3:00 就会自动执行备份。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;远程服务器同步配置&lt;/h2&gt;
&lt;p&gt;如果想把备份同步到另一台服务器，需要配置 SSH 免密登录。&lt;/p&gt;
&lt;h3&gt;1. 生成 SSH 密钥&lt;/h3&gt;
&lt;p&gt;在本机执行：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ssh-keygen -t rsa -b 4096 -f ~/.ssh/my_backup_key
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后把生成的 &lt;code&gt;my_backup_key.pub&lt;/code&gt;​ 复制到远程服务器：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ssh-copy-id -i ~/.ssh/my_backup_key.pub user@remote_server_ip
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;修改脚本配置：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;enable_ssh_sync=true  # 是否启用 SSH 同步（true/false）
enable_ssh_clean=true  # 是否清理远程服务器上的备份（true/false）
ssh_ip=&quot;182.22.13.33&quot;  # 远程服务器 IP
ssh_port=22  # SSH 端口
ssh_user=&quot;root&quot;  # 登录远程服务器的用户名
clientPath=&quot;/home/backup/mysql/&quot;  # 远程服务器存储路径
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;配置完成后，脚本运行时就能把备份同步到远程服务器了。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;阿里云 OSS 备份配置&lt;/h2&gt;
&lt;h3&gt;1. 安装 &lt;code&gt;ossutil&lt;/code&gt;​&lt;/h3&gt;
&lt;p&gt;需要安装 ossutil，阿里云有十分完善的文档，点击查看：&lt;a href=&quot;https://help.aliyun.com/zh/oss/developer-reference/install-ossutil2&quot;&gt;安装 ossutil&lt;/a&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;仅需完成官方文档中的第一步安装 ossutil 使其命令可用即可，不需要执行官方文档中的 &lt;code&gt;ossutil config&lt;/code&gt;​ 对 ossutil 进行配置&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;2. 创建 Bucket&lt;/h3&gt;
&lt;p&gt;进入 &lt;a href=&quot;https://oss.console.aliyun.com/bucket&quot;&gt;对象存储 OSS - Bucket 列表&lt;/a&gt;，创建 Bucket，用于存放数据库备份文件&lt;/p&gt;
&lt;h3&gt;3. 获取阿里云 AccessKey&lt;/h3&gt;
&lt;p&gt;进入 &lt;a href=&quot;https://ram.console.aliyun.com/profile/access-keys&quot;&gt;阿里云 AccessKey 管理&lt;/a&gt;，创建 AccessKey 并记录 &lt;code&gt;AccessKey ID&lt;/code&gt;​ 和 &lt;code&gt;AccessKey Secret&lt;/code&gt;​。&lt;/p&gt;
&lt;h3&gt;3. 修改脚本配置&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;enable_oss_upload=true  # 是否启用 OSS 上传（true/false）
enable_oss_clean=true  # 是否清理阿里云 OSS 上的备份（true/false）
oss_bucket=&quot;oss://Bucket名称/想存储的文件夹路径/&quot;  # OSS 目标路径
oss_access_key=&quot;your-access-key-id&quot;  # 阿里云 AccessKey
oss_secret_key=&quot;your-access-key-secret&quot;  # 阿里云 Secret
oss_endpoint=&quot;oss-cn-hangzhou.aliyuncs.com&quot;  # OSS 访问地址，在对应 bucket 的概览中可以看到外网访问的 Endpoint
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;配置完成后，脚本运行时备份文件就会自动上传到 OSS 了。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;结语&lt;/h2&gt;
&lt;p&gt;这个脚本能帮助你轻松管理 MySQL 备份，支持本地存储、远程同步和阿里云 OSS 备份。如果你也需要定期备份数据库，可以试试这个仓库：&lt;/p&gt;
&lt;p&gt;👉 &lt;strong&gt;&lt;a href=&quot;https://github.com/zxc7563598/mysql-backup-shell&quot;&gt;GitHub 仓库 - mysql-backup-shell&lt;/a&gt;&lt;/strong&gt; 🚀&lt;/p&gt;
&lt;p&gt;如果有任何问题或改进建议，欢迎提 Issue 或 Fork 进行优化！&lt;/p&gt;</content:encoded><h:img src="/_astro/mysql.C4E994Tu.png"/><enclosure url="/_astro/mysql.C4E994Tu.png"/></item><item><title>一个PHPer的偷懒哲学：如何用两套模板跳过重复造轮子</title><link>https://hejunjie.life/blog/f04d98cd</link><guid isPermaLink="true">https://hejunjie.life/blog/f04d98cd</guid><description>一套为PHPer和全栈开发者打造的懒人工具包：基于Vue3+Webman实现开箱即用的后台管理系统模板，内置RBAC权限、接口加解密、统一配置中心等常用模块，帮你跳过重复造轮子的繁琐配置，5分钟启动新项目。免费开源，个人开发者友好</description><pubDate>Mon, 24 Mar 2025 06:00:49 GMT</pubDate><content:encoded>&lt;p&gt;作为一个 PHPer，在做新项目的时候，配权限系统、调接口加密、搞菜单路由... 这些代码就像 &lt;strong&gt;开发者的家务活&lt;/strong&gt;——技术含量不高，但总得有人干。&lt;/p&gt;
&lt;p&gt;像是后台，直接从以前做完的项目拷的话还要删不少东西，而如果直接从比较成熟的像是 BuildAdmin 或者 Vue Naive Admin 之类的项目直接开始的话，又免不了要做很多配置，而且后台总会涉及到角色管理啊，角色权限管理啊，菜单管理，管理员管理一类的东西。这些东西都浪费了我们大量的时间，所以我做了这两个仓库。&lt;/p&gt;
&lt;p&gt;于是我把这些年攒的 &lt;strong&gt;“重复代码包”&lt;/strong&gt; 提炼成了两个开箱即用的仓库：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/zxc7563598/vue-admin-essentials&quot;&gt;vue-admin-essentials —— 删繁就简的后台模板&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/zxc7563598/php-webman-essentials&quot;&gt;php-webman-essentials —— 即插即用的 PHP 脚手架&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;一、这俩仓库解决什么痛点？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;后台管理三大件不用重写&lt;/strong&gt;
&lt;ul&gt;
&lt;li&gt;用户权限（RBAC 四件套：用户+角色+菜单+权限）&lt;/li&gt;
&lt;li&gt;接口规范（统一响应格式/错误码/数据加解密）&lt;/li&gt;
&lt;li&gt;基础配置（前后端的.env 文件藏着 90%的配置项）&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;消灭联调时的经典对话&lt;/strong&gt;&lt;br&gt;
👨💻 前端：“你这接口怎么又跨域了？”&lt;br&gt;
👨💻 后端：“稍等，我再调下 CORS 中间件...” → &lt;strong&gt;现在默认配好了&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;拒绝过度设计&lt;/strong&gt;&lt;br&gt;
没引入微服务/没加复杂工作流/没用花哨组件库 → &lt;strong&gt;只保留中小项目真用得上的功能&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;二、技术栈与核心设计&lt;/h2&gt;
&lt;p&gt;| 前端仓库亮点                  | 后端仓库巧思                              |
| ----------------------------- | ----------------------------------------- |
| ✅ 基于 Naive UI 清爽的组件库 | ✅ Webman 协程提升并发能力                |
| ✅ 请求响应自动加解密         | ✅ 异常处理统一接管（不再满屏 try-catch） |
| ✅ 动态路由自动生成           | ✅ 权限中间件一行代码接入                 |&lt;/p&gt;
&lt;h2&gt;三、适合哪些场景？&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;🚀 需要两天内搭出管理后台的紧急项目&lt;/li&gt;
&lt;li&gt;🧑💻 个人开发者接外包时的快速起手式&lt;/li&gt;
&lt;li&gt;🧩 教学项目需要演示标准权限系统&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;四、一些其他的&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;为什么是 webman&lt;/strong&gt;&lt;br&gt;
没什么特别的深意，只是我个人觉得 HyPerf 在涉及大量微服务，或者说需要更多内置服务的大型项目比较好，而我这边大部分项目都称不上造火箭，Webman 开启协程后 HyPerf 也没有特别大的性能优势，所以选择了相对比较轻量的 webman&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;我的配置习惯可能和你不同&lt;/strong&gt;&lt;br&gt;
比如把错误码定义在语言包、用 AES+RSA 混合加密 → 不习惯的话直接改代码就好，项目结构足够干净。&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h2&gt;最后&lt;/h2&gt;
&lt;p&gt;如果你也受够了这些 &lt;strong&gt;「每个项目都要配一次」&lt;/strong&gt; 的琐事，不妨试试这两个仓库。用爽了可以点个 Star。&lt;/p&gt;</content:encoded><h:img src="/_astro/php.DoApYH8-.png"/><enclosure url="/_astro/php.DoApYH8-.png"/></item><item><title>用装饰器模式实现多层缓存：让PHP应用更快更稳</title><link>https://hejunjie.life/blog/78d10aa8</link><guid isPermaLink="true">https://hejunjie.life/blog/78d10aa8</guid><description>通过装饰器模式实现PHP多层缓存架构，详解如何利用内存、Redis、文件缓存组合提升应用性能。包含设计思路、代码示例与实战效果对比，助您构建高效缓存策略</description><pubDate>Tue, 11 Mar 2025 07:26:29 GMT</pubDate><content:encoded>&lt;h2&gt;为什么要做多层缓存？&lt;/h2&gt;
&lt;p&gt;想象这样一个场景：你的 PHP 应用每次访问数据库都要花 1 秒钟，用户抱怨页面加载太慢。这时候你会想到加缓存——但&lt;strong&gt;只用一层缓存够吗？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;内存缓存&lt;/strong&gt;虽然快，但重启服务数据就没了&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Redis 缓存&lt;/strong&gt;能持久化，但网络请求也有开销&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;文件缓存&lt;/strong&gt;最可靠，但磁盘读写速度有限&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;多层缓存的思路很简单&lt;/strong&gt;：&lt;br&gt;
把最快的缓存放在最前面，就像快递柜一样——&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;优先从内存取（速度最快）&lt;/li&gt;
&lt;li&gt;内存没有再查 Redis（速度中等）&lt;/li&gt;
&lt;li&gt;Redis 没有最后查文件或数据库（速度最慢但最可靠）&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这样既能&lt;strong&gt;减少对慢速存储的访问&lt;/strong&gt;，又能&lt;strong&gt;保证数据最终可用性&lt;/strong&gt;。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;为什么选择装饰器模式？&lt;/h2&gt;
&lt;p&gt;假设我们要实现这样的调用链：&lt;br&gt;
​&lt;code&gt;内存缓存 → Redis缓存 → 文件缓存 → 数据库&lt;/code&gt;​&lt;/p&gt;
&lt;p&gt;如果用传统继承方式：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;// 伪代码：噩梦般的多层继承
class MemoryThenRedisThenFileCache extends FileCache {
    // 要重写所有方法...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;而&lt;strong&gt;装饰器模式&lt;/strong&gt;就像俄罗斯套娃：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;// 真实使用示例：自由组合
$cache = new MemoryCache(
    new RedisCache(
        new FileCache(
            new DatabaseSource()
        ),
        [
			&apos;host&apos; =&gt; &apos;127.0.0.1&apos;
            &apos;port&apos; =&gt; 6379,
            &apos;password&apos; =&gt; null,
            &apos;ttl&apos; =&gt; 3600
		]
    ),
	300
	1024
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;三大优势&lt;/strong&gt;：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;灵活组合&lt;/strong&gt;：随时换缓存顺序，比如把 Redis 放最外层&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;代码干净&lt;/strong&gt;：每个类只关注自己的缓存逻辑&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;易于扩展&lt;/strong&gt;：新增缓存类型只需写一个新类&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h2&gt;怎么用？三行代码搞定&lt;/h2&gt;
&lt;p&gt;该模块已经在我 PHP 的常用工具库中实现，可以通过 composer 集成到项目 &lt;a href=&quot;https://github.com/zxc7563598/php-tools&quot;&gt;点击查看 GitHub 与文档&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;可以通过该命令安装： &lt;code&gt;composer require hejunjie/tools&lt;/code&gt;​&lt;/p&gt;
&lt;p&gt;假设已经安装了这个 composer 包：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;use Hejunjie\Tools\Cache\Decorators;
// 1. 创建基础数据源（比如数据库查询类）
$dbSource = new DatabaseSource();

// 2. 像套娃一样包裹缓存层
$cache = new Decorators\MemoryCache(           // 第一层：内存
    new Decorators\RedisCache(                 // 第二层：Redis - 未安装 redis 则去掉该层
        new Decorators\FileCache(              // 第三层：文件
            $dbSource, 						   // 第四层：数据库（用户自定义）
            &apos;[文件]缓存文件夹路径&apos;,
            &apos;[文件]缓存时长(秒)&apos;
        ),
        &apos;[redis]配置&apos;
        &apos;[redis]前缀&apos;
        &apos;[redis]是否持久化链接&apos;
    ),
    &apos;[内存]缓存时长(秒)&apos;,
    &apos;[内存]缓存数量(防止内存溢出)&apos;
);

// 3. 无感知使用（自动走缓存链）
$data = $cache-&gt;get(&apos;user_123&apos;);    // 自动按 内存 → Redis → 文件 → 数据库 顺序查找，找到后立即返回不继续向后调用；返回时根据查找顺序倒序返回并自动存储

$cache-&gt;set(&apos;user_123&apos;, &apos;张三&apos;);    // 同时更新所有缓存层
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;实际效果对比&lt;/h2&gt;
&lt;p&gt;| 场景       | 无缓存 | 单层缓存 | 三层缓存         |
| ---------- | ------ | -------- | ---------------- |
| 读取速度   | 1.2s   | 0.3s     | 0.05ms           |
| 数据库压力 | 100%   | 30%      | &amp;#x3C;5%             |
| 服务重启后 | 正常   | 缓存失效 | 仍有文件缓存兜底 |&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;为什么推荐这个设计？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;像搭积木一样简单&lt;/strong&gt;&lt;br&gt;
随时增删缓存层，比如临时去掉 Redis：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;$cache = new Decorators\MemoryCache(           // 第一层：内存
    new Decorators\FileCache(                  // 第二层：文件
        $dbSource, 							   // 第三层：数据库（用户自定义）
        &apos;[文件]缓存文件夹路径&apos;,
        &apos;[文件]缓存时长(秒)&apos;
    ),
    &apos;[内存]缓存时长(秒)&apos;,
    &apos;[内存]缓存数量(防止内存溢出)&apos;
);
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;安全有保障&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;文件缓存自动加锁防止冲突&lt;/li&gt;
&lt;li&gt;Redis 自动重连机制&lt;/li&gt;
&lt;li&gt;内存缓存限制最大条目数&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;看得见的效果&lt;/strong&gt;&lt;br&gt;
内存缓存自带统计面板：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;print_r($cache-&gt;getStats());
/* 输出：
[
    &apos;hits&apos; =&gt; 2953,      // 命中次数
    &apos;misses&apos; =&gt; 47,      // 未命中次数
    &apos;hit_rate&apos; =&gt; 0.984, // 命中率98.4%
    &apos;items&apos; =&gt; 1024      // 当前缓存条目
]
*/
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h2&gt;适合什么场景？&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;高频读取的数据（如商品信息）&lt;/li&gt;
&lt;li&gt;需要快速响应的 API 接口&lt;/li&gt;
&lt;li&gt;数据库压力大的系统&lt;/li&gt;
&lt;li&gt;希望服务重启后快速恢复&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;总结&lt;/h2&gt;
&lt;p&gt;通过装饰器模式实现多层缓存，就像给应用穿上了多层保暖衣：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;内层（内存）&lt;/strong&gt; ：最贴身，响应最快&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;中层（Redis）&lt;/strong&gt; ：保持温度，持久化&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;外层（文件）&lt;/strong&gt; ：防风防雪，绝对可靠&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这种设计用简单的代码实现了灵活高效的缓存策略，下次当你遇到性能瓶颈时，不妨试试这种&quot;套娃式&quot;的解决方案吧！&lt;/p&gt;</content:encoded><h:img src="/_astro/php.DoApYH8-.png"/><enclosure url="/_astro/php.DoApYH8-.png"/></item><item><title>微信小程序开发全流程：从注册到上线的完整指南</title><link>https://hejunjie.life/blog/6f0f15a3</link><guid isPermaLink="true">https://hejunjie.life/blog/6f0f15a3</guid><description>这篇文章详细记录了微信小程序的完整开发流程，包括从注册、创建项目、编写代码、审核备案到最终上线的每一个步骤。适合对小程序开发感兴趣的个人开发者或希望了解完整流程的学习者，涵盖了云开发、事件绑定、生命周期管理、组件使用等关键内容</description><pubDate>Tue, 04 Mar 2025 08:02:41 GMT</pubDate><content:encoded>&lt;p&gt;最近在刷小红书时，看到不少人在分享自己的微信小程序如何靠广告月入上万，甚至更多。&lt;/p&gt;
&lt;p&gt;这种说法不能说不真实，只能说肯定不是这么简单的事情。毕竟广告收入的多少，归根结底还是取决于用户量，不可能随便做个小程序，就能吸引大量用户来看广告。&lt;/p&gt;
&lt;p&gt;不过，完全说不可能也不太准确，毕竟人活着总得有梦想。而且其实做一个简单的小程序成本并不高，尤其得益于云开发。小程序后期没有域名、服务器等额外的软成本，所有内容都可以部署在微信平台上。如果小程序没有火，每个月基本上只需要支付 20 元的基础费用，这几乎就是唯一的支出。所以，如果广告收入能超过 20 元，那就算是赚到了。&lt;/p&gt;
&lt;p&gt;正好最近有点空闲时间，我也挺好奇微信小程序的开发流程相比之前有什么变化，索性就研究了一下，并把整个过程记录下来，方便以后参考。于是，就有了这篇文章。&lt;/p&gt;
&lt;p&gt;‍&lt;/p&gt;
&lt;h2&gt;开发前准备&lt;/h2&gt;
&lt;h3&gt;注册微信公众平台&lt;/h3&gt;
&lt;p&gt;首先前往 &lt;a href=&quot;https://mp.weixin.qq.com&quot;&gt;微信公众平台&lt;/a&gt; 并扫码登录。如果是新用户，扫码时会引导你进行网页注册。&lt;/p&gt;
&lt;p&gt;注册所需的资料并不复杂，但需要注意微信对不同注册主体的限制。个人主体可以注册小程序，但无法使用支付等涉及资金交易的功能。这也是为什么小红书上的宣传总是强调广告收入。毕竟，除非真有富哥富姐玩真心话大冒险输了私下给你转账，否则广告收入几乎是个人主体小程序唯一的盈利方式。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;注册链接：https://mp.weixin.qq.com/cgi-bin/registermidpage?action=index&amp;#x26;lang=zh_CN&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;链接：https://mp.weixin.qq.com&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;​&lt;img src=&quot;article/6f0f15a3/%E6%B3%A8%E5%86%8C1-20250303114708-w4iev76.png&quot; alt=&quot;注册第一步：选择小程序&quot;&gt;​&lt;/p&gt;
&lt;p&gt;​&lt;img src=&quot;article/6f0f15a3/%E6%B3%A8%E5%86%8C2-20250303114708-9onf51t.png&quot; alt=&quot;注册第二步：填写邮箱以及账号密码&quot;&gt;​&lt;/p&gt;
&lt;p&gt;​&lt;img src=&quot;article/6f0f15a3/%E6%B3%A8%E5%86%8C3-20250303114708-c5e24us.png&quot; alt=&quot;注册第三步：验证邮箱&quot;&gt;​&lt;/p&gt;
&lt;p&gt;​&lt;img src=&quot;article/6f0f15a3/%E6%B3%A8%E5%86%8C4-20250303114708-sdtgiwi.png&quot; alt=&quot;注册第四步：完善信息&quot;&gt;​&lt;/p&gt;
&lt;h3&gt;创建微信小程序&lt;/h3&gt;
&lt;p&gt;一个微信公众平台账号，简单来说就是一个 &lt;strong&gt;开发者账号&lt;/strong&gt;，它对应着一个 &lt;strong&gt;AppID（小程序 ID）&lt;/strong&gt; 。&lt;/p&gt;
&lt;p&gt;微信官方提供了 &lt;strong&gt;微信开发者工具&lt;/strong&gt;，用于小程序的开发。&lt;/p&gt;
&lt;p&gt;因此，小程序的开发过程可以概括为：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;注册账号&lt;/li&gt;
&lt;li&gt;获取 AppID&lt;/li&gt;
&lt;li&gt;下载并安装微信开发者工具&lt;/li&gt;
&lt;li&gt;在微信开发者工具中创建项目，绑定 AppID 后开始开发&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;微信开发者工具下载地址：https://developers.weixin.qq.com/miniprogram/dev/devtools/stable.html&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;​&lt;img src=&quot;article/6f0f15a3/%E5%BC%80%E5%8F%91%E8%80%85ID-20250303130309-f0io0a6.png&quot; alt=&quot;如何查看小程序的AppID&quot;&gt;​&lt;/p&gt;
&lt;h3&gt;可选：小程序备案&lt;/h3&gt;
&lt;p&gt;已经确定小程序内容（名称 / logo）的情况下，可以提前进行小程序备案&lt;/p&gt;
&lt;p&gt;详见：&lt;a href=&quot;#%E5%B0%8F%E7%A8%8B%E5%BA%8F%E5%A4%87%E6%A1%88&quot;&gt;小程序备案&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;‍&lt;/p&gt;
&lt;h2&gt;开始开发&lt;/h2&gt;
&lt;p&gt;小程序的开发方式与 Vue 类似，但整体上经过了微信的“魔改”。通过类似 HTML 的 WXML 构建页面，类似 CSS 的 WXSS 描述样式，以及通过类似 JavaScript 的 WXJS 渲染页面。&lt;/p&gt;
&lt;p&gt;需要注意的是，虽然 WXSS 与 CSS 基本相似，但许多 CSS3 特性并未被继承到 WXSS 中。此外，小程序的渲染方式与传统网页有所不同，因此在开发时需要特别关注页面样式在不同设备上的兼容性和展示效果。&lt;/p&gt;
&lt;h3&gt;整体说明&lt;/h3&gt;
&lt;p&gt;小程序创建后，会得到这样一个基础目录&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;your-app/
│
├── assets/            # 存放静态资源文件（如图片、字体等）
│   ├── logo.png
│   └── bg.jpg
│
├── components/            # 自定义组件
│   ├── header/            # 头部组件文件夹
│   │   ├── header.wxml    # 组件的结构文件
│   │   ├── header.wxss    # 组件的样式文件
│   │   ├── header.js      # 组件的逻辑文件
│   │   └── header.json    # 组件的配置文件
│   │
│   └── footer/            # 底部组件文件夹
│       ├── footer.wxml
│       ├── footer.wxss
│       ├── footer.js
│       └── footer.json
│
├── pages/             # 存放小程序页面
│   ├── index/         # 首页页面文件夹
│   │   ├── index.wxml  # 页面结构文件（HTML-like）
│   │   ├── index.wxss  # 页面样式文件（CSS-like）
│   │   ├── index.js    # 页面逻辑文件（JS）
│   │   └── index.json  # 页面配置文件
│   │
│   └── other/         # 其他页面
│       ├── other.wxml
│       ├── other.wxss
│       ├── other.js
│       └── other.json
│
├── utils/             # 存放工具函数等公共代码
│   ├── util.js
│   └── helper.js
│
├── app.js             # 小程序的入口文件
├── app.json           # 小程序全局配置文件
├── app.wxss           # 小程序全局样式文件
└── project.config.json# 小程序项目配置文件（IDE用）

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;‍&lt;/p&gt;
&lt;h3&gt;页面说明&lt;/h3&gt;
&lt;p&gt;小程序的每个页面或组件通常由同名的 wxml、wxss、js 和 json 四个文件构成，这些文件之间会自动关联，无需额外的引用配置。&lt;/p&gt;
&lt;h4&gt;&lt;strong&gt;​&lt;code&gt;wxml&lt;/code&gt;​&lt;/strong&gt;​ &lt;strong&gt;（微信标记语言，类似 HTML）&lt;/strong&gt;&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;负责页面的结构和布局，定义页面中的元素。&lt;/li&gt;
&lt;li&gt;通过 WXML 来编写页面的视图结构。&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;#x3C;!-- wxml 文件 --&gt;
&amp;#x3C;view class=&quot;container&quot;&gt;
  &amp;#x3C;text&gt;{{title}}&amp;#x3C;/text&gt;
&amp;#x3C;/view&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;‍&lt;/p&gt;
&lt;h4&gt;&lt;code&gt;wxss&lt;/code&gt;​（微信样式表，类似 CSS）&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;负责页面的样式设置，定义页面中元素的外观、布局等。&lt;/li&gt;
&lt;li&gt;​&lt;code&gt;wxss&lt;/code&gt;​ 是 CSS 的一种扩展，支持大部分标准的 CSS 特性，同时增加了小程序特有的一些功能，如尺寸单位 &lt;code&gt;rpx&lt;/code&gt;​（响应式像素）。&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;/* wxss 文件 */
.container {
  width: 100%;
  padding: 20px;
  background-color: #f0f0f0;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;‍&lt;/p&gt;
&lt;h4&gt;​&lt;code&gt;js&lt;/code&gt;​（JavaScript）&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;用于页面的逻辑处理，包括数据的处理、事件的绑定、页面生命周期的管理等。&lt;/li&gt;
&lt;li&gt;通过 &lt;code&gt;Page()&lt;/code&gt;​ 或 &lt;code&gt;Component()&lt;/code&gt;​ 定义页面的行为和生命周期方法（如 &lt;code&gt;onLoad&lt;/code&gt;​、&lt;code&gt;onShow&lt;/code&gt;​ 等）。&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// js 文件
Page({
  data: {
    title: &apos;Hello, 小程序!&apos;
  },
  onLoad: function () {
    console.log(&apos;页面加载&apos;)
  }
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;‍&lt;/p&gt;
&lt;h4&gt;&lt;code&gt;json&lt;/code&gt;​（配置文件）&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;用于页面的配置，比如页面的导航栏、标题、窗口背景色等。&lt;/li&gt;
&lt;li&gt;页面和组件的配置也通过 &lt;code&gt;json&lt;/code&gt;​ 文件来指定，包括页面是否启用分享、是否使用微信的下拉刷新等。&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;navigationBarTitleText&quot;: &quot;首页&quot;,
  &quot;enablePullDownRefresh&quot;: true
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;‍&lt;/p&gt;
&lt;h4&gt;组件&lt;/h4&gt;
&lt;p&gt;组件与页面类似，由同名的 &lt;code&gt;wxml&lt;/code&gt;​、&lt;code&gt;wxss&lt;/code&gt;​、&lt;code&gt;js&lt;/code&gt;​ 和 &lt;code&gt;json&lt;/code&gt;​ 四个文件组成。&lt;/p&gt;
&lt;p&gt;可以简单理解为，组件就是一个“可复用的小页面”或“功能模块”。&lt;/p&gt;
&lt;p&gt;组件的意义在于封装可复用的 UI 结构和逻辑。例如，在小程序中，每个页面可能都会包含一个相同的标题栏，如果不使用组件，就需要在每个页面单独编写一遍，而使用组件后，只需封装一个标题栏组件，并在各个页面中引用即可。这样不仅减少了冗余代码，提高了开发效率，还能在需要调整时，只修改组件代码就能同步更新所有页面，避免遗漏或重复修改。&lt;/p&gt;
&lt;h5&gt;组件的引用&lt;/h5&gt;
&lt;p&gt;在对应页面的 &lt;code&gt;json&lt;/code&gt;​ 配置文件（如 &lt;code&gt;index.json&lt;/code&gt;​）中，使用 &lt;code&gt;usingComponents&lt;/code&gt;​ 引入组件。例如，假设组件位于 &lt;code&gt;components/my-component/my-component&lt;/code&gt;​ 目录下：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;usingComponents&quot;: {
    &quot;my-component&quot;: &quot;/components/my-component/my-component&quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在页面的 &lt;code&gt;wxml&lt;/code&gt;​ 文件中，像 HTML 标签一样使用组件：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;#x3C;my-component&gt;&amp;#x3C;/my-component&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;‍&lt;/p&gt;
&lt;h3&gt;生命周期&lt;/h3&gt;
&lt;p&gt;小程序根据 &lt;code&gt;js&lt;/code&gt;​ 中的 &lt;code&gt;Page()&lt;/code&gt;​ 或 &lt;code&gt;Component()&lt;/code&gt;​ 来区分页面或组件，他们会有各自不同的生命周期&lt;/p&gt;
&lt;p&gt;小程序的开发多数都围绕着页面的生命周期进行&lt;/p&gt;
&lt;h4&gt;页面&lt;/h4&gt;
&lt;p&gt;页面中的自定义方法如下图表格所示：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;页面进行到对应节点时便会触发对应的生命周期函数，可以不进行声明，如果不进行声明则不会被触发&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;| 生命周期函数        | 触发时机                               | 适用场景                           |
| :------------------ | :------------------------------------- | :--------------------------------- |
| onLoad(options)     | 页面 加载 时触发（仅触发一次）         | 获取页面跳转参数、初始化数据       |
| onReady()           | 页面 首次渲染完成 时触发（仅触发一次） | 获取 DOM 节点信息                  |
| onShow()            | 页面 每次显示 时触发                   | 页面进入前台，适合刷新数据         |
| onHide()            | 页面 隐藏 时触发                       | 页面跳转到其他页面                 |
| onUnload()          | 页面 卸载 时触发                       | 适用于释放资源、清除定时器         |
| onPullDownRefresh() | 用户 下拉刷新 时触发                   | 刷新页面数据（需要在 json 里开启） |
| onReachBottom()     | 用户 滚动到底部 时触发                 | 适用于分页加载数据                 |
| onShareAppMessage() | 用户点击 分享按钮 时触发               | 自定义分享内容                     |
| onShareTimeline()   | 用户点击 分享到朋友圈 时触发           | 适用于朋友圈分享                   |
| onPageScroll(event) | 页面 滚动时 触发                       | 监听滚动位置                       |
| onResize(event)     | 页面 尺寸变化 时触发                   | 适用于屏幕旋转等情况               |&lt;/p&gt;
&lt;p&gt;注意：小程序的页面管理方式和浏览器的 &lt;strong&gt;单页应用（SPA）&lt;/strong&gt; 类似，采用的是 &lt;strong&gt;堆栈管理机制&lt;/strong&gt;，即：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;页面不会被立即销毁&lt;/strong&gt;，而是存储在页面栈中。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;新页面入栈&lt;/strong&gt;，旧页面仍然保留在内存中，不会触发 &lt;code&gt;onLoad&lt;/code&gt;​。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;返回旧页面时，旧页面不会重新加载&lt;/strong&gt;，但会触发 &lt;code&gt;onShow&lt;/code&gt;​。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;| 操作             | onLoad  | onShow  | onHide  | onUnload     |
| :--------------- | :------ | :------ | :------ | :----------- |
| 进入页面 A       | ✅ 触发 | ✅ 触发 | ❌      | ❌           |
| 从 A 跳转到 B    | ❌      | ❌      | ✅ 触发 | ❌           |
| 返回 A（B -&gt; A） | ❌      | ✅ 触发 | ❌      | ❌           |
| A -&gt; B -&gt; 关闭 B | ❌      | ✅ 触发 | ❌      | ✅（B 卸载） |&lt;/p&gt;
&lt;h5&gt;示例&lt;/h5&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;Page({
  /**
   * 页面的初始数据
   *
   * 页面的初始数据是一个对象，可以包含各种属性，这些属性将用于页面渲染
   * 页面的数据会在页面加载时自动传入页面的 wxml
   * 通过 data 设置的数据，可以在 wxml 中进行绑定，从而在页面中渲染
   * 例如下面的数据，可以直接在 wxml 中使用 {{message}} 进行展示
   * data 中的数据可以在页面的 js 中通过 this.data 访问
   * 通过 this.setData 方法修改 data 中的数据，将重新渲染页面
   */
  data: {
    message: &apos;Hello, 小程序!&apos;,
    count: 0
  },

  /**
   * 生命周期函数 - 监听页面加载
   * 只在页面首次加载时触发一次
   * 可用于获取页面参数、初始化数据等
   */
  onLoad(options) {
    console.log(&apos;页面加载：onLoad&apos;, options)
    // 可以从 options 获取页面跳转时传递的参数
    if (options.id) {
      console.log(&apos;页面参数 ID:&apos;, options.id)
    }
  },

  /**
   * 生命周期函数 - 监听页面初次渲染完成
   * 页面结构已渲染，但未必可见
   * 适合操作 DOM
   */
  onReady() {
    console.log(&apos;页面渲染完成：onReady&apos;)
  },

  /**
   * 生命周期函数 - 监听页面显示
   * 每次页面进入前台时触发（包括返回该页面）
   */
  onShow() {
    console.log(&apos;页面显示：onShow&apos;)
  },

  /**
   * 生命周期函数 - 监听页面隐藏
   * 进入后台或跳转到其他页面时触发
   */
  onHide() {
    console.log(&apos;页面隐藏：onHide&apos;)
  },

  /**
   * 生命周期函数 - 监听页面卸载
   * 页面被关闭/销毁时触发（如 `wx.navigateBack()` 返回）
   * 适合执行清理操作，如释放定时器
   */
  onUnload() {
    console.log(&apos;页面卸载：onUnload&apos;)
  },

  /**
   * 监听用户下拉动作（用于刷新页面）
   * 需要在 `page.json` 里开启 `&quot;enablePullDownRefresh&quot;: true`
   */
  onPullDownRefresh() {
    console.log(&apos;用户触发下拉刷新：onPullDownRefresh&apos;)
    // 模拟数据刷新
    setTimeout(() =&gt; {
      this.setData({ message: &apos;数据已刷新&apos; })
      wx.stopPullDownRefresh() // 停止刷新动画
    }, 1000)
  },

  /**
   * 页面上拉触底事件（用于加载更多数据）
   * 适用于分页加载数据
   */
  onReachBottom() {
    console.log(&apos;页面滚动到底部：onReachBottom&apos;)
    this.setData({ count: this.data.count + 1 })
  },

  /**
   * 监听用户点击右上角分享（可自定义分享内容）
   * 仅 `onShareAppMessage` 适用于普通分享
   */
  onShareAppMessage() {
    console.log(&apos;用户点击分享：onShareAppMessage&apos;)
    return {
      title: &apos;这是一个分享标题&apos;,
      path: &apos;/pages/index/index?id=123&apos; // 可以携带参数
    }
  },

  /**
   * 监听用户点击右上角分享到朋友圈
   * 仅 `onShareTimeline` 适用于分享到朋友圈
   */
  onShareTimeline() {
    console.log(&apos;用户分享到朋友圈：onShareTimeline&apos;)
    return {
      title: &apos;分享到朋友圈的标题&apos;
    }
  },

  /**
   * 页面滚动触发（可用于监听滚动位置）
   */
  onPageScroll(event) {
    console.log(&apos;页面滚动：onPageScroll&apos;, event.scrollTop)
  },

  /**
   * 页面尺寸变化时触发（通常用于适配屏幕旋转）
   */
  onResize(event) {
    console.log(&apos;页面尺寸变化：onResize&apos;, event)
  }
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;‍&lt;/p&gt;
&lt;h4&gt;组件&lt;/h4&gt;
&lt;p&gt;组件的行为与 &lt;strong&gt;页面&lt;/strong&gt; 类似。 通常来说 &lt;strong&gt;组件不会自动卸载&lt;/strong&gt;，而是 &lt;strong&gt;随页面一起缓存&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;组件的生命周期与页面不同，具体生命周期如下：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;组件进行到对应节点时便会触发对应的生命周期函数，可以不进行声明，如果不进行声明则不会被触发&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;| 生命周期                   | 作用                                           |
| :------------------------- | :--------------------------------------------- |
| created()                  | 组件实例化时触发，数据未绑定，DOM 结构未生成   |
| attached()                 | 组件挂载到页面节点树时触发，可获取 properties  |
| ready()                    | 组件视图渲染完成，适合操作 DOM                 |
| moved()                    | 组件从一个位置移动到另一个位置（较少使用）     |
| detached()                 | 组件被移除，适合清理资源（如定时器、监听事件） |
| error()                    | 组件内部发生错误时触发                         |
| pageLifetimes.show()       | 组件所在的页面 onShow 时触发                   |
| pageLifetimes.hide()       | 组件所在的页面 onHide 时触发                   |
| pageLifetimes.resize(size) | 组件所在页面尺寸发生变化时触发                 |&lt;/p&gt;
&lt;p&gt;注意：小程序的组件的生命周期比页面更精细，具体行为取决于组件的使用方式，有以下几个点需要被注意：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;组件不会随页面 &lt;code&gt;onShow()&lt;/code&gt;​ 触发，需要手动更新数据。&lt;/li&gt;
&lt;li&gt;使用 &lt;code&gt;wx:if&lt;/code&gt;​ 可以让组件重新创建，从而刷新数据。&lt;/li&gt;
&lt;li&gt;使用 &lt;code&gt;hidden&lt;/code&gt;​ 只是隐藏组件，不会销毁。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;| 行为              | 页面 onShow() 触发？ | 组件 attached() 触发？ | 组件 detached() 触发？ |
| :---------------- | :------------------- | :--------------------- | :--------------------- |
| wx.navigateTo()   | ✅                   | ❌                     | ❌                     |
| wx.navigateBack() | ✅                   | ❌                     | ❌                     |
| wx.switchTab()    | ✅                   | ❌                     | ❌                     |
| wx:if 控制组件    | -                    | ✅（组件被重新创建）   | ✅（组件被销毁）       |
| hidden 控制组件   | -                    | ❌                     | ❌                     |&lt;/p&gt;
&lt;h5&gt;示例&lt;/h5&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;Component({
  /**
   * 组件的属性列表（外部传入）
   */
  properties: {
    title: {
      type: String,
      value: &apos;默认标题&apos;
    },
    count: {
      type: Number,
      value: 0
    }
  },

  /**
   * 组件的内部数据
   */
  data: {
    innerValue: &apos;内部数据&apos;
  },

  /**
   * 组件的方法列表
   */
  methods: {
    /**
     * 自定义方法 - 增加计数
     */
    increment() {
      this.setData({
        count: this.data.count + 1
      })
      this.triggerEvent(&apos;countChanged&apos;, { count: this.data.count }) // 触发事件，通知父组件
    },

    /**
     * 自定义方法 - 组件被点击
     */
    handleTap() {
      console.log(&apos;组件被点击&apos;)
      this.triggerEvent(&apos;componentTap&apos;, { message: &apos;组件被点击了&apos; })
    }
  },

  /**
   * 组件的生命周期函数
   */
  lifetimes: {
    /**
     * created：组件实例化时触发（在组件未进入页面节点树时）
     */
    created() {
      console.log(&apos;组件实例化：created&apos;)
    },

    /**
     * attached：组件挂载到页面时触发（类似于页面的 onLoad）
     */
    attached() {
      console.log(&apos;组件挂载到页面：attached&apos;)
    },

    /**
     * ready：组件视图渲染完成（类似于页面的 onReady）
     */
    ready() {
      console.log(&apos;组件视图渲染完成：ready&apos;)
    },

    /**
     * moved：组件被移动到其他节点（很少使用）
     */
    moved() {
      console.log(&apos;组件被移动：moved&apos;)
    },

    /**
     * detached：组件从页面移除时触发（类似于页面的 onUnload）
     */
    detached() {
      console.log(&apos;组件被销毁：detached&apos;)
    },

    /**
     * error：组件内部发生错误时触发（如 setData 失败）
     */
    error(err) {
      console.error(&apos;组件发生错误：&apos;, err)
    }
  },

  /**
   * 旧版生命周期（与 lifetimes 功能类似，可兼容旧版小程序）
   */
  pageLifetimes: {
    /**
     * 组件所在的页面显示时触发（类似于 onShow）
     */
    show() {
      console.log(&apos;组件所在页面显示：pageLifetimes.show&apos;)
    },

    /**
     * 组件所在的页面隐藏时触发（类似于 onHide）
     */
    hide() {
      console.log(&apos;组件所在页面隐藏：pageLifetimes.hide&apos;)
    },

    /**
     * 组件所在的页面卸载时触发（类似于 onUnload）
     */
    resize(size) {
      console.log(&apos;组件所在页面尺寸变化：pageLifetimes.resize&apos;, size)
    }
  }
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;‍&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;组件的写法与页面近乎无二，通常建议将会重复在页面中出现的部分单独写成组件，这样只要在不同的页面中引用即可，页面与组件之间可以相互传递数据&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;‍&lt;/p&gt;
&lt;h3&gt;事件绑定&lt;/h3&gt;
&lt;p&gt;在微信小程序中，事件绑定是页面与用户交互的核心。事件可以绑定到页面元素上，当用户与元素交互时，触发相应的 JavaScript 方法，从而实现预期的操作。&lt;/p&gt;
&lt;p&gt;小程序提供了多种事件类型，主要如下：&lt;/p&gt;
&lt;p&gt;| 事件类型            | 说明                                   |
| :------------------ | :------------------------------------- |
| tap                 | 轻触事件（点击）                       |
| longpress / longtap | 长按（750ms 以上）                     |
| touchstart          | 手指触摸屏幕                           |
| touchmove           | 手指在屏幕上滑动                       |
| touchend            | 手指离开屏幕                           |
| touchcancel         | 触摸被打断，如来电                     |
| scroll              | 滚动事件                               |
| input               | 输入框内容变化                         |
| blur                | 输入框失去焦点                         |
| focus               | 输入框获得焦点                         |
| change              | 选项改变（picker、checkbox、radio 等） |
| confirm             | 输入框回车事件                         |
| submit              | 表单提交事件                           |
| load                | 图片加载完成                           |
| error               | 组件加载失败                           |&lt;/p&gt;
&lt;p&gt;‍&lt;/p&gt;
&lt;h4&gt;事件的绑定方式&lt;/h4&gt;
&lt;p&gt;微信小程序中存在多种事件绑定方式，如下：&lt;/p&gt;
&lt;p&gt;在 &lt;code&gt;wxml&lt;/code&gt;​ 中，事件绑定是以 &lt;code&gt;绑定方式+事件类型=&quot;js中的函数名&quot;&lt;/code&gt;​&lt;/p&gt;
&lt;p&gt;| 方式          | 说明                           |
| :------------ | :----------------------------- |
| bind          | 事件会冒泡                     |
| catch         | 事件不会冒泡                   |
| capture-bind  | 事件在捕获阶段触发（先父后子） |
| capture-catch | 事件在捕获阶段触发，且不会冒泡 |&lt;/p&gt;
&lt;h4&gt;事件的冒泡&lt;/h4&gt;
&lt;p&gt;微信小程序的事件分为&lt;strong&gt;冒泡事件&lt;/strong&gt;和&lt;strong&gt;非冒泡事件&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这些事件会从子组件向父组件传递：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;​&lt;code&gt;tap&lt;/code&gt;​（点击）&lt;/li&gt;
&lt;li&gt;​&lt;code&gt;longpress&lt;/code&gt;​（长按）&lt;/li&gt;
&lt;li&gt;​&lt;code&gt;touchstart&lt;/code&gt;​ / &lt;code&gt;touchmove&lt;/code&gt;​ / &lt;code&gt;touchend&lt;/code&gt;​（触摸）&lt;/li&gt;
&lt;li&gt;​&lt;code&gt;confirm&lt;/code&gt;​（键盘回车）&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;冒泡事件可以理解为，当在 wxml 中，一个视图容器嵌套另一个视图容器的情况下，在点击内部视图容器时，外部容器的对应事件也会被响应，因为本质上内部组件整个本身就是外部组件内部的一部分&lt;/p&gt;
&lt;p&gt;以下文为例：&lt;/p&gt;
&lt;p&gt;🔹 &lt;strong&gt;​&lt;code&gt;bindtap=&quot;childTap&quot;&lt;/code&gt;​&lt;/strong&gt; ​ → 事件会冒泡，父组件的 &lt;code&gt;parentTap()&lt;/code&gt;​ 也会触发&lt;br&gt;
🔹 &lt;strong&gt;​&lt;code&gt;catchtap=&quot;childTap&quot;&lt;/code&gt;​&lt;/strong&gt; ​ → 事件不会冒泡，&lt;code&gt;parentTap()&lt;/code&gt;​ 不会触发&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;#x3C;view bindtap=&quot;parentTap&quot;&gt;
  &amp;#x3C;view bindtap=&quot;childTap&quot; catchtap=&quot;childTap&quot;&gt;点击我&amp;#x3C;/view&gt;
&amp;#x3C;/view&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;Page({
  parentTap(event) {
    console.log(&apos;父组件被点击&apos;)
  },
  childTap(event) {
    console.log(&apos;子组件被点击&apos;)
  }
})
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;事件对象&lt;/h4&gt;
&lt;p&gt;所有事件都会带一个 &lt;code&gt;event&lt;/code&gt;​ 参数：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;handleTap(event) {
  console.log(event);
  // event.currentTarget // 事件绑定的元素
  // event.target // 实际触发事件的元素
  // event.detail // 事件的详细信息（如 `input` 输入的值）
  // event.touches // 当前触摸点
  // event.timeStamp // 事件时间
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;示例：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;#x3C;view bindtap=&quot;handleTap&quot; data-id=&quot;123&quot;&gt;点击我&amp;#x3C;/view&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;handleTap(event) {
  console.log(event.currentTarget.dataset.id); // 输出 &quot;123&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;绑定事件的元素可以通过 &lt;code&gt;data-&lt;/code&gt;​ 来赋值，在事件方法中可以通过 &lt;code&gt;event.currentTarget.dataset&lt;/code&gt;​ 来获取&lt;/p&gt;
&lt;p&gt;即为：如果 WXML 中 填写的内容为 &lt;code&gt;&amp;#x3C;view bindtap=&quot;handleTap&quot; data-demo=&quot;123&quot;&gt;点击我&amp;#x3C;/view&gt;&lt;/code&gt;​ ，那么在 &lt;code&gt;handleTap&lt;/code&gt;​ 方法中就可以通过 &lt;code&gt;event.currentTarget.dataset.demo&lt;/code&gt;​ 来获取值&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4&gt;组件的自定义事件&lt;/h4&gt;
&lt;p&gt;组件同样支持自定义事件，其事件类型和绑定方式与页面一致。不同之处在于，组件的自定义事件需要在 &lt;code&gt;methods&lt;/code&gt;​ 对象中定义相应的方法。&lt;/p&gt;
&lt;p&gt;组件的自定义事件中，可以通过 &lt;code&gt;this.triggerEvent&lt;/code&gt;​ 方法向页面传递数据，如下图所示：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;子组件：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;#x3C;button bindtap=&quot;sendData&quot;&gt;点击发送&amp;#x3C;/button&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;Component({
  methods: {
    sendData() {
      this.triggerEvent(&apos;myEvent&apos;, { name: &apos;小程序&apos; })
    }
  }
})
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;父组件：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;#x3C;custom-component bind:myEvent=&quot;handleEvent&quot;&gt;&amp;#x3C;/custom-component&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;Page({
  handleEvent(event) {
    console.log(event.detail.name) // &quot;小程序&quot;
  }
})
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;‍&lt;/p&gt;
&lt;h3&gt;云开发&lt;/h3&gt;
&lt;p&gt;说实话，云开发这个东西很难说有多好。如果是正儿八经的企业项目，还是建议自备服务器，因为自建服务器能提供更大的后端操作空间，很多逻辑都可以交由后端处理，灵活性更高。&lt;/p&gt;
&lt;p&gt;云开发的核心包括 &lt;strong&gt;云函数、云存储&lt;/strong&gt; 和 &lt;strong&gt;云数据库&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;云函数&lt;/strong&gt;：支持远程部署 Node.js 代码，并且可以被微信小程序免鉴权直接调用（类似于内网调用，不需要额外考虑安全验证）。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;云数据库&lt;/strong&gt;：提供类 MongoDB 的 NoSQL 数据库，或支持 MySQL。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;云存储&lt;/strong&gt;：用于存储各类文件资源。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;从功能上来看，云开发已经具备了基础的服务器端能力，但其中也存在一些非常诡异的软限制。例如，在使用云数据库时，每次查询最多只能返回 20 条数据，想获取更多数据必须通过分页的方式逐步查询。&lt;/p&gt;
&lt;p&gt;不过，整体来看，如果只是一个小微项目，为了一个访问量不高的小程序特意准备一台服务器，确实有些浪费。而云开发采用按量计费模式，最低 20 元的套餐就能满足基本需求，相比自建服务器，成本低了不少。所以是否选择云开发，还是要看具体需求，见仁见智。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;注意：&lt;/strong&gt; 使用云开发能力前，需要通过 &lt;code&gt;wx.cloud.init()&lt;/code&gt;​ 进行初始化，一般建议在根目录 &lt;code&gt;app.js&lt;/code&gt;​ 中的 &lt;code&gt;onLaunch&lt;/code&gt;​ 方法中执行一次即可&lt;/p&gt;
&lt;p&gt;‍&lt;/p&gt;
&lt;h4&gt;云存储&lt;/h4&gt;
&lt;h5&gt;上传&lt;/h5&gt;
&lt;p&gt;在方法中，直接使用 &lt;code&gt;wx.cloud.uploadFile&lt;/code&gt;​ 方法就可以上传文件&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;​&lt;code&gt;cloudPath&lt;/code&gt;​：文件存储在云端的路径，可以是任意字符串（建议加文件夹结构）。&lt;/li&gt;
&lt;li&gt;​&lt;code&gt;filePath&lt;/code&gt;​：本地文件路径，比如 &lt;code&gt;wx.chooseImage()&lt;/code&gt;​ 选择的图片路径。&lt;/li&gt;
&lt;li&gt;​&lt;code&gt;success&lt;/code&gt;​：上传成功的回调，&lt;code&gt;res.fileID&lt;/code&gt;​ 是文件的唯一标识，可用于后续访问。&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;wx.cloud.uploadFile({
  cloudPath: &apos;example-folder/my-image.png&apos;, // 存储路径
  filePath: tempFilePath, // 本地文件路径（通过 wx.chooseImage 获取）
  success: (res) =&gt; {
    console.log(&apos;上传成功，文件ID：&apos;, res.fileID)
  },
  fail: (err) =&gt; {
    console.error(&apos;上传失败&apos;, err)
  }
})
&lt;/code&gt;&lt;/pre&gt;
&lt;h5&gt;访问或下载&lt;/h5&gt;
&lt;p&gt;上传成功后，可以通过 &lt;code&gt;fileID&lt;/code&gt;​ 获取文件的临时访问 URL&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;wx.cloud.getTempFileURL({
  fileList: [&apos;cloud://your-env-id/example-folder/my-image.png&apos;],
  success: (res) =&gt; {
    console.log(&apos;文件可访问地址：&apos;, res.fileList[0].tempFileURL)
  },
  fail: (err) =&gt; {
    console.error(&apos;获取文件 URL 失败&apos;, err)
  }
})
&lt;/code&gt;&lt;/pre&gt;
&lt;h5&gt;删除文件&lt;/h5&gt;
&lt;p&gt;如果需要删除云存储中的文件，可以使用 &lt;code&gt;wx.cloud.deleteFile&lt;/code&gt;​ 方法&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;wx.cloud.deleteFile({
  fileList: [&apos;cloud://your-env-id/example-folder/my-image.png&apos;],
  success: (res) =&gt; {
    console.log(&apos;删除成功&apos;, res.fileList)
  },
  fail: (err) =&gt; {
    console.error(&apos;删除失败&apos;, err)
  }
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;‍&lt;/p&gt;
&lt;h4&gt;云函数&lt;/h4&gt;
&lt;p&gt;云函数的核心在于“云端函数”本身，而非小程序的功能。简单来说，可以理解为你在微信云平台上部署了一段 Node.js 代码，并为该云函数指定一个名称。之后，你就可以通过 &lt;code&gt;wx.cloud.callFunction&lt;/code&gt;​ 来调用这个函数了。&lt;/p&gt;
&lt;p&gt;大致流程如下：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;wx.cloud.callFunction({
  name: &apos;cloud-name&apos;, // 云函数名称
  data: {}, // 传递给云函数的数据
  success: (res) =&gt; {
    console.log(&apos;云函数返回结果：&apos;, res)
  },
  fail: (err) =&gt; {
    console.error(&apos;云函数调用失败&apos;, err)
  }
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;有些聪明的朋友可能会开始纠结：小程序已经可以直接使用云数据库和云存储了，云函数的意义到底在哪儿呢？&lt;/p&gt;
&lt;p&gt;其实，云函数的作用非常明显。如果你需要对用户上传的数据进行清洗、计算、合并等复杂操作，或者需要进行云端的高级数据处理，这时候云函数就显得非常有用。直接在客户端进行这些处理可能会加重小程序的负担，而使用云函数可以将这些逻辑封装到云端，从而减轻客户端的计算压力，并提升性能。&lt;/p&gt;
&lt;p&gt;另外，如果你是企业主体，可能需要与第三方服务进行集成，比如支付处理、短信发送、邮件服务或社交登录等。这些外部服务通常需要在后端进行操作，而不适合直接在小程序客户端实现。通过云函数，你可以安全地与外部 API 进行交互，并将结果传递回小程序。&lt;/p&gt;
&lt;p&gt;最后，如果你有定时任务（例如定期推送消息、清理数据库、生成报告等），云函数也可以在云端定时执行，而不依赖客户端的操作。&lt;/p&gt;
&lt;h4&gt;云数据库&lt;/h4&gt;
&lt;p&gt;首先你需要获取数据库实例，通常是通过 &lt;code&gt;wx.cloud.database()&lt;/code&gt;​ 获取：&lt;/p&gt;
&lt;p&gt;可以在页面使用数据库时声明，也可以在小程序启动时在 &lt;code&gt;app.js&lt;/code&gt;​ 中注册为全局变量&lt;/p&gt;
&lt;h5&gt;查询数据&lt;/h5&gt;
&lt;p&gt;查询数据使用 &lt;code&gt;get()&lt;/code&gt;​ 方法，它返回的是一个 &lt;code&gt;Promise&lt;/code&gt;​ 对象，可以通过 &lt;code&gt;.then()&lt;/code&gt;​ 或 &lt;code&gt;async/await&lt;/code&gt;​ 来获取数据。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const db = wx.cloud.database()
db.collection(&apos;users&apos;).get({
  success: (res) =&gt; {
    console.log(&apos;查询结果&apos;, res.data) // 返回查询的所有数据
  },
  fail: (err) =&gt; {
    console.error(&apos;查询失败&apos;, err)
  }
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;你也可以根据条件查询数据，微信小程序提供了 &lt;code&gt;db.command&lt;/code&gt;​ 来处理这些操作符，支持如下：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;​&lt;code&gt;gt()&lt;/code&gt;​：大于&lt;/li&gt;
&lt;li&gt;​&lt;code&gt;lt()&lt;/code&gt;​：小于&lt;/li&gt;
&lt;li&gt;​&lt;code&gt;gte()&lt;/code&gt;​：大于等于&lt;/li&gt;
&lt;li&gt;​&lt;code&gt;lte()&lt;/code&gt;​：小于等于&lt;/li&gt;
&lt;li&gt;​&lt;code&gt;eq()&lt;/code&gt;​：等于&lt;/li&gt;
&lt;li&gt;​&lt;code&gt;neq()&lt;/code&gt;​：不等于&lt;/li&gt;
&lt;li&gt;​&lt;code&gt;in()&lt;/code&gt;​：在某个数组内&lt;/li&gt;
&lt;li&gt;​&lt;code&gt;and()&lt;/code&gt;​：多个条件联合&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果我们想要获取 &lt;code&gt;users&lt;/code&gt;​ 表中 &lt;code&gt;city = &apos;成都&apos; and age &gt; 25&lt;/code&gt;​ 的用户&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;db.collection(&apos;users&apos;)
  .where({
    age: db.command.gt(25),
    city: &apos;成都&apos;
  })
  .get({
    success: (res) =&gt; {
      console.log(&apos;查询结果&apos;, res.data)
    },
    fail: (err) =&gt; {
      console.error(&apos;查询失败&apos;, err)
    }
  })
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;‍&lt;/p&gt;
&lt;h5&gt;新增数据&lt;/h5&gt;
&lt;p&gt;在云数据库中插入数据使用 &lt;code&gt;add()&lt;/code&gt;​ 方法。你可以将数据以对象的形式传入，并且云数据库会自动生成 &lt;code&gt;_id&lt;/code&gt;​, &lt;code&gt;_openid&lt;/code&gt;​。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const db = wx.cloud.database()
db.collection(&apos;users&apos;).add({
  data: {
    name: &apos;张三&apos;,
    age: 25,
    city: &apos;成都&apos;
  },
  success: (res) =&gt; {
    console.log(&apos;数据插入成功&apos;, res)
  },
  fail: (err) =&gt; {
    console.error(&apos;数据插入失败&apos;, err)
  }
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;‍&lt;/p&gt;
&lt;h5&gt;更新数据&lt;/h5&gt;
&lt;p&gt;更新数据使用 &lt;code&gt;update()&lt;/code&gt;​ 方法，可以根据 &lt;code&gt;_id&lt;/code&gt;​ 来指定要更新的文档并修改某些字段的值。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const db = wx.cloud.database()
db.collection(&apos;users&apos;)
  .doc(&apos;文档ID&apos;)
  .update({
    data: {
      age: 26
    },
    success: (res) =&gt; {
      console.log(&apos;数据更新成功&apos;, res)
    },
    fail: (err) =&gt; {
      console.error(&apos;数据更新失败&apos;, err)
    }
  })
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;‍&lt;/p&gt;
&lt;h5&gt;删除数据&lt;/h5&gt;
&lt;p&gt;删除数据使用 &lt;code&gt;remove()&lt;/code&gt;​ 方法，删除指定 &lt;code&gt;_id&lt;/code&gt;​ 的数据。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const db = wx.cloud.database()
db.collection(&apos;users&apos;)
  .doc(&apos;文档ID&apos;)
  .remove({
    success: (res) =&gt; {
      console.log(&apos;数据删除成功&apos;, res)
    },
    fail: (err) =&gt; {
      console.error(&apos;数据删除失败&apos;, err)
    }
  })
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;‍&lt;/p&gt;
&lt;h5&gt;一些小坑&lt;/h5&gt;
&lt;p&gt;云数据库更多操作可以查看&lt;a href=&quot;https://developers.weixin.qq.com/miniprogram/dev/wxcloudservice/wxcloud/guide/database/init.html&quot;&gt;官方文档&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;云数据库看似很爽，但存在一些很隐蔽的坑，大概如下：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;微信云数据库的查询操作默认每次最多返回 &lt;strong&gt;20 条数据&lt;/strong&gt;。如果你想查询更多数据，必须进行分页查询，使用 &lt;strong&gt;​&lt;code&gt;skip()&lt;/code&gt;​&lt;/strong&gt; ​ 和 &lt;strong&gt;​&lt;code&gt;limit()&lt;/code&gt;​&lt;/strong&gt; ​ 进行分页操作。你需要自己控制分页逻辑，每次获取一页数据，直到没有更多数据为止。&lt;/li&gt;
&lt;li&gt;云数据库支持的查询条件比较简单，适用于大部分常见的需求，但也有一些限制。比如不支持正则查询，所有的查询都需要通过明确的字段条件进行。无法直接执行 OR 查询，只能通过多个条件查询。复杂的逻辑需要通过多个 where 调用或者在查询后手动合并结果。&lt;/li&gt;
&lt;li&gt;云数据库不支持真正的批量写入操作，如批量更新、批量删除等。如果需要进行大量数据的批量处理，可能需要循环处理每一条记录，且会有一定的性能开销。&lt;/li&gt;
&lt;li&gt;云数据库采用的是强一致性模型，意味着每次写操作（如 &lt;code&gt;add&lt;/code&gt;​、&lt;code&gt;update&lt;/code&gt;​）会确保数据在全局范围内同步一致。如果你需要对数据进行并发写操作（如多人同时修改数据），需要特别注意数据冲突的问题。如果多个用户同时修改同一条数据，可能会出现冲突，云数据库本身不提供冲突解决机制，需要你自己处理相关的业务逻辑。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;‍&lt;/p&gt;
&lt;h2&gt;完成开发后&lt;/h2&gt;
&lt;h3&gt;备案与认证&lt;/h3&gt;
&lt;p&gt;在微信小程序正式上线前，需要完成 &lt;strong&gt;备案&lt;/strong&gt; 和 &lt;strong&gt;认证&lt;/strong&gt; 两个重要步骤。&lt;/p&gt;
&lt;h4&gt;&lt;strong&gt;小程序备案&lt;/strong&gt;&lt;/h4&gt;
&lt;p&gt;备案流程共分为三步：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;补充小程序基本信息&lt;/strong&gt;：包括名称、图标、描述等。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;设置主营类目&lt;/strong&gt;：选择小程序所属的业务类别，确保符合微信的运营规范。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;提交审核&lt;/strong&gt;：首先由腾讯进行审核，审核通过后提交至管局进行最终备案。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;备案通过后，小程序才具备发布正式版本的资格，否则无法正常对外运营。内容不复杂，个人主体提供下身份证，个人信息，邮箱那些基本就可以了&lt;/p&gt;
&lt;p&gt;当天或者第二天腾讯就会打电话过来，不会有太多问题，主要就是核对下个人信息是否准确，询问你姓名，身份证后六位，要审核的小程序名字，他的功能那些&lt;/p&gt;
&lt;p&gt;中间突然问了个生肖，估计是为了防止第三方备案&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;备案主要卡在管局那边，通常需要三五天到一星期，不会对小程序的内容进行审查，因此也可以在开发前进行备案，可以有效节省时间&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;​&lt;img src=&quot;article/6f0f15a3/%E6%9D%90%E6%96%99-20250303172709-a0h3l16.png&quot; alt=&quot;备案材料一览&quot;&gt;​&lt;/p&gt;
&lt;h4&gt;&lt;strong&gt;小程序认证&lt;/strong&gt;&lt;/h4&gt;
&lt;p&gt;认证是为了确保小程序主体的合法性和运营合规性。微信会委托 &lt;strong&gt;第三方审核机构&lt;/strong&gt; 对以下内容进行审核：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;主体真实性&lt;/strong&gt;：核验个人或企业的身份信息。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;行业资质有效性&lt;/strong&gt;：检查小程序从事的业务是否符合相关行业要求。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;命名合规性&lt;/strong&gt;：确保小程序名称符合微信命名规则。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;线上服务可用性&lt;/strong&gt;：测试小程序功能是否能正常使用。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;个人认证的费用为 &lt;strong&gt;30 元&lt;/strong&gt;，企业认证的费用为 &lt;strong&gt;300 元/年&lt;/strong&gt;。只有通过认证后，小程序才允许被 &lt;strong&gt;分享&lt;/strong&gt; 和 &lt;strong&gt;搜索&lt;/strong&gt;，否则仅能通过扫码或直接访问进行使用。&lt;/p&gt;
&lt;h2&gt;最后&lt;/h2&gt;
&lt;p&gt;在微信开发者工具中，开发者可以直接 &lt;strong&gt;上传&lt;/strong&gt; 代码。上传后，代码会出现在 &lt;strong&gt;版本管理 → 开发管理&lt;/strong&gt; 页面，确认无误后即可提交审核。&lt;/p&gt;
&lt;p&gt;在提交审核之前，务必 &lt;strong&gt;充分测试&lt;/strong&gt; 小程序的功能和稳定性。因为 &lt;strong&gt;一旦审核通过，代码就可以正式发布&lt;/strong&gt;（前提是已完成备案/认证）。如果提交了尚未经过完整测试的版本，可能会影响用户体验，甚至导致线上 bug。&lt;/p&gt;
&lt;p&gt;审核过程通常较快，一般 &lt;strong&gt;1 个工作日内&lt;/strong&gt; 即可完成。不过需要注意的是，&lt;strong&gt;小程序发布后如果在 90 天内未完成备案，将无法继续使用&lt;/strong&gt;。因此，建议 &lt;strong&gt;等备案完成后&lt;/strong&gt; 再进行正式发布，以免因备案问题影响小程序的正常运营。&lt;/p&gt;
&lt;p&gt;​&lt;img src=&quot;article/6f0f15a3/%E6%9C%AA%E5%91%BD%E5%90%8D%202-20250304155435-52uzuom.png&quot; alt=&quot;开发管理&quot;&gt;​&lt;/p&gt;
&lt;p&gt;至此，从 &lt;strong&gt;注册&lt;/strong&gt; 微信小程序，到 &lt;strong&gt;开发&lt;/strong&gt;、&lt;strong&gt;调试&lt;/strong&gt;，再到 &lt;strong&gt;备案与认证&lt;/strong&gt;，整个流程基本走完了。如果你是第一次开发小程序，可能会觉得过程繁琐，但实际上，微信提供的云开发、开发者工具等已经大大降低了开发门槛，~~虽然微信的文档依然依托答辩~~&lt;/p&gt;
&lt;p&gt;当然，技术只是基础，&lt;strong&gt;小程序最终能否成功，还是要看产品的价值和用户体验&lt;/strong&gt;。如果只是想尝试一下小程序开发，那云开发是个不错的选择，能省去服务器的成本和后端开发的麻烦。但如果希望深入发展，还是建议结合自己的需求，考虑是否需要自建后端，或者使用更灵活的技术方案。&lt;/p&gt;</content:encoded><h:img src="/_astro/wechat.CCGUIBc5.jpeg"/><enclosure url="/_astro/wechat.CCGUIBc5.jpeg"/></item><item><title>使用 Python 合并微信与支付宝账单，生成财务报告</title><link>https://hejunjie.life/blog/234fc7f9</link><guid isPermaLink="true">https://hejunjie.life/blog/234fc7f9</guid><description>这篇博客介绍了如何使用 Python 脚本合并微信与支付宝账单数据，生成自动化财务报告。通过 pandas 库，学习如何清洗、合并和分析账单数据，以及如何生成 Markdown 格式的财务报告。适合对财务自动化和数据处理感兴趣的开发者</description><pubDate>Tue, 21 Jan 2025 15:16:04 GMT</pubDate><content:encoded>&lt;p&gt;最近用思源笔记记东西上瘾，突然想每个月存一份收支记录进去。但手动整理账单太麻烦了，支付宝导出一份 CSV，微信又导出一份，格式还不一样，每次复制粘贴头都大。&lt;/p&gt;
&lt;p&gt;干脆写了个 Python 脚本一键处理，核心就干两件事：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;把俩平台的 CSV 账单合并到一起&lt;/li&gt;
&lt;li&gt;自动生成带分类表格的 Markdown（直接拖进思源就能渲染）&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;代码主要折腾了这些：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;支付宝账单前 24 行都是废话，直接 &lt;code&gt;skiprows=24&lt;/code&gt; 跳过去，GBK 编码差点让我栽跟头&lt;/li&gt;
&lt;li&gt;微信账单的列名和支付宝对不上，比如微信叫 &lt;strong&gt;交易单号&lt;/strong&gt; ，支付宝叫 &lt;strong&gt;交易订单号&lt;/strong&gt; ，通过 &lt;code&gt;rename&lt;/code&gt; 强行对齐&lt;/li&gt;
&lt;li&gt;两边金额都有 &lt;strong&gt;¥&lt;/strong&gt; 符号和逗号（比如 ¥1,200），用正则 &lt;code&gt;[¥￥,]&lt;/code&gt; 替换成数字&lt;/li&gt;
&lt;li&gt;最后合并数据时发现微信少几个字段（比如“对方账号”），直接填个 pd.NA 占位&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;最爽的是生成 Markdown 的部分，pandas 分组统计消费类型，直接 for 循环拼字符串，出来效果长这样：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;article/234fc7f9/demo.png#pic_center&quot; alt=&quot;生成样式示例，数据内容随机生成&quot;&gt;&lt;/p&gt;
&lt;h2&gt;使用说明&lt;/h2&gt;
&lt;p&gt;脚本依赖两个 Python 包：&lt;code&gt;pandas&lt;/code&gt; 和 &lt;code&gt;chardet&lt;/code&gt;。安装方法如下：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pip install pandas chardet
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;准备账单文件&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;支付宝账单&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;打开支付宝 App → 我的 → 账单 → 点击右上角「···」 → 开具交易流水证明 → 用于个人对账&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;微信账单&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;打开微信 App → 我的 → 服务 → 钱包 → 账单 → 常见问题 → 下载账单 → 用于个人对账&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;将这两个文件放到脚本所在的文件夹中。&lt;/p&gt;
&lt;p&gt;修改代码底部&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# 调用函数读取 CSV 文件并生成新的 CSV 文件
read_csv(&apos;支付宝账单路径.csv&apos;, &apos;微信账单路径.csv&apos;, &apos;生成合并账单路径&apos;)
# 调用函数生成 Markdown 文件
generate_markdown(&apos;生成合并账单路径.csv&apos;, &apos;最终账单.md&apos;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;运行脚本，即可得到 &lt;code&gt;最终账单.md&lt;/code&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;python analysis.py
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;完整代码（或访问 &lt;a href=&quot;https://github.com/zxc7563598/alipay-wechat-finance&quot;&gt;GitHub 仓库&lt;/a&gt;&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import pandas as pd

def read_csv(alipay_path, wechat_path, output_path):
    try:
        # 读取 alipay.csv 文件，跳过前 24 行，从第 25 行开始
        alipay = pd.read_csv(alipay_path, skiprows=24, encoding=&apos;GBK&apos;)

        # 读取 wechat.csv 文件
        wechat = pd.read_csv(wechat_path, skiprows=16)

        # 必需的列名
        required_columns = [&apos;交易订单号&apos;, &apos;交易分类&apos;, &apos;交易对方&apos;, &apos;对方账号&apos;, &apos;商品说明&apos;, &apos;收/支&apos;, &apos;金额&apos;, &apos;收/付款方式&apos;, &apos;交易状态&apos;, &apos;备注&apos;, &apos;交易时间&apos;]

        # 确保 alipay 数据包含必要的列
        if all(col in alipay.columns for col in required_columns):
            # 选择 alipay.csv 中需要的列
            alipay_selected = alipay[required_columns]
        else:
            print(&quot;alipay.csv 文件缺少必要的列。&quot;)
            raise ValueError(&quot;alipay.csv 列不完整&quot;)

        # 重命名 wechat.csv 中的列以匹配 required_columns
        wechat_columns_map = {
            &apos;交易单号&apos;: &apos;交易订单号&apos;, &apos;交易类型&apos;: &apos;交易分类&apos;, &apos;商品&apos;: &apos;商品说明&apos;, &apos;金额(元)&apos;: &apos;金额&apos;, &apos;支付方式&apos;: &apos;收/付款方式&apos;, &apos;当前状态&apos;: &apos;交易状态&apos;
        }

        # 重命名 wechat 的列
        wechat.rename(columns=wechat_columns_map, inplace=True)

        # 对 wechat.csv 进行列重命名和缺失列填充
        wechat_selected = pd.DataFrame(columns=required_columns)  # 创建一个空的 DataFrame，列名为 required_columns

        # 复制 wechat.csv 中已有的列
        for col in wechat.columns:
            if col in required_columns:
                wechat_selected[col] = wechat[col]

        # 对于 wechat.csv 中没有的列，填充空值（NaN）
        for col in required_columns:
            if col not in wechat_selected.columns:
                wechat_selected[col] = &quot;/&quot;

        # 去掉 &apos;收/支&apos; 列中值为 &apos;不计收支&apos; 的行
        alipay_selected = alipay_selected[alipay_selected[&apos;收/支&apos;] != &apos;不计收支&apos;]
        wechat_selected = wechat_selected[wechat_selected[&apos;收/支&apos;] != &apos;/&apos;]

        # 去掉 &apos;金额&apos; 列中的 &apos;¥&apos; 或 &apos;￥&apos; 符号，以及千位分隔符，并转换为浮点数
        wechat_selected[&apos;金额&apos;] = wechat_selected[&apos;金额&apos;].str.replace(r&apos;[¥￥,]&apos;, &apos;&apos;, regex=True).astype(float)

        # 为 alipay_selected 和 wechat_selected 添加「分类」列
        alipay_selected[&apos;分类&apos;] = &apos;支付宝&apos;
        wechat_selected[&apos;分类&apos;] = &apos;微信&apos;

        # 将 alipay 和 wechat 数据合并
        combined_data = pd.concat([alipay_selected, wechat_selected], ignore_index=True)

        # 将合并后的 DataFrame 保存为新的 CSV 文件
        combined_data.to_csv(output_path, index=False)
        print(f&quot;文件已成功保存为 &apos;{output_path}&apos;&quot;)
    except FileNotFoundError:
        print(&quot;文件未找到，请检查文件路径。&quot;)
    except pd.errors.ParserError:
        print(&quot;读取 CSV 文件时出现问题，请检查文件格式或编码。&quot;)
    except Exception as e:
        print(f&quot;发生错误：{e}&quot;)

def generate_markdown(csv_file, output_file):
    # 自动检测文件编码
    import chardet
    with open(csv_file, &apos;rb&apos;) as f:
        result = chardet.detect(f.read())
        encoding = result[&apos;encoding&apos;]

    # 读取文件
    data = pd.read_csv(csv_file, encoding=encoding)

    # 去除金额列中的符号和千分位逗号，转换为数值型
    data[&apos;金额&apos;] = data[&apos;金额&apos;].replace({&apos;¥&apos;: &apos;&apos;, &apos;,&apos;: &apos;&apos;}, regex=True).astype(float)

    # 计算本月消费总额和收入总额
    total_expense = data[data[&apos;收/支&apos;] == &apos;支出&apos;][&apos;金额&apos;].sum()
    total_income = data[data[&apos;收/支&apos;] == &apos;收入&apos;][&apos;金额&apos;].sum()

    # 计算每个分类的金额
    expense_by_transaction = data[data[&apos;收/支&apos;] == &apos;支出&apos;].groupby(&apos;交易分类&apos;)[&apos;金额&apos;].sum().sort_values(ascending=False)
    income_by_transaction = data[data[&apos;收/支&apos;] == &apos;收入&apos;].groupby(&apos;交易分类&apos;)[&apos;金额&apos;].sum().sort_values(ascending=False)

    # 计算本月结余
    total_balance = total_income - total_expense

    # 打印调试信息
    print(f&quot;Total Expense: {total_expense}&quot;)
    print(f&quot;Total Income: {total_income}&quot;)
    print(f&quot;Total Balance: {total_balance}&quot;)

    # 生成 markdown 内容
    markdown_content = f&quot;**本月消费总额**：￥{total_expense:.2f}  |  **本月收入总额**：￥{total_income:.2f}  |  **本月结余**：￥{total_balance:.2f}\n\n&quot;


    # 消费类型分析
    markdown_content += &quot;## 消费类型分析 💸\n\n&quot;
    markdown_content += &quot;以下是各消费交易分类与消费金额：\n\n&quot;
    markdown_content += &quot;| 交易分类   | 消费金额   |\n&quot;
    markdown_content += &quot;| ---------- | ---------- |\n&quot;
    for transaction, amount in expense_by_transaction.items():
        markdown_content += f&quot;| {transaction} | ￥{amount:.2f} |\n&quot;

    markdown_content += &quot;\n### 每个交易分类的详细记录：\n&quot;
    for transaction in expense_by_transaction.index:
        markdown_content += f&quot;\n#### {transaction}消费记录 💳\n&quot;
        transaction_data = data[(data[&apos;收/支&apos;] == &apos;支出&apos;) &amp;#x26; (data[&apos;交易分类&apos;] == transaction)]
        markdown_content += &quot;| 交易对方  |  金额  | 分类 | 交易时间 |\n&quot;
        markdown_content += &quot;| -------- | ----- | ------ | -------- |\n&quot;
        for _, row in transaction_data.iterrows():
            markdown_content += f&quot;| {row[&apos;交易对方&apos;]} | ￥{row[&apos;金额&apos;]:.2f} | {row[&apos;分类&apos;]} | {row[&apos;交易时间&apos;]} |\n&quot;

    # 收入类型分析
    markdown_content += &quot;\n## 收入类型分析 💵\n\n&quot;
    markdown_content += &quot;以下是各收入交易分类与收入金额：\n\n&quot;
    markdown_content += &quot;| 交易分类   | 收入金额   |\n&quot;
    markdown_content += &quot;| ---------- | ---------- |\n&quot;
    for transaction, amount in income_by_transaction.items():
        markdown_content += f&quot;| {transaction} | ￥{amount:.2f} |\n&quot;

    markdown_content += &quot;\n### 每个交易分类的详细记录：\n&quot;
    for transaction in income_by_transaction.index:
        markdown_content += f&quot;\n#### {transaction}收入记录 💼\n&quot;
        transaction_data = data[(data[&apos;收/支&apos;] == &apos;收入&apos;) &amp;#x26; (data[&apos;交易分类&apos;] == transaction)]
        markdown_content += &quot;| 交易对方  |  金额  | 分类 | 交易时间 |\n&quot;
        markdown_content += &quot;| -------- | ----- | ------ | -------- |\n&quot;
        for _, row in transaction_data.iterrows():
            markdown_content += f&quot;| {row[&apos;交易对方&apos;]} | ￥{row[&apos;金额&apos;]:.2f} | {row[&apos;分类&apos;]} | {row[&apos;交易时间&apos;]} |\n&quot;

    # 生成收支明细
    markdown_content += &quot;\n## 收支明细\n&quot;
    data_sorted = data.sort_values(by=&apos;交易时间&apos;)
    markdown_content += &quot;| 交易分类 | 分类 | 收/支 | 金额 | 交易对方 | 商品说明 | 对方账号 | 收/付款方式 | 交易状态 | 备注 | 交易时间 |\n&quot;
    markdown_content += &quot;| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |\n&quot;

    for _, row in data_sorted.iterrows():
        markdown_content += f&quot;| {row[&apos;交易分类&apos;]} | {row[&apos;分类&apos;]} | {row[&apos;收/支&apos;]} | ￥{row[&apos;金额&apos;]:.2f} | {row[&apos;交易对方&apos;]} | {row[&apos;商品说明&apos;]} | {row[&apos;对方账号&apos;]} | {row[&apos;收/付款方式&apos;]} | {row[&apos;交易状态&apos;]} | {row[&apos;备注&apos;]} | {row[&apos;交易时间&apos;]} |\n&quot;

    # 保存生成的 markdown 到文件
    with open(output_file, &apos;w&apos;, encoding=&apos;utf-8&apos;) as f:
        f.write(markdown_content)

    print(f&quot;Markdown 已成功生成并保存为 &apos;{output_file}&apos;&quot;)

# 调用示例
# 调用函数读取 CSV 文件并生成新的 CSV 文件
read_csv(&apos;./bill/alipay_record_20250201_091025.csv&apos;, &apos;./bill/微信支付账单(20250101-20250201)——【解压密码可在微信支付公众号查看】.csv&apos;, &apos;./bill/合并账单.csv&apos;)
# 调用函数生成 Markdown 文件
generate_markdown(&apos;./bill/合并账单.csv&apos;, &apos;./bill/账单.md&apos;)
&lt;/code&gt;&lt;/pre&gt;</content:encoded><h:img src="/_astro/python.DaFi4Dwd.png"/><enclosure url="/_astro/python.DaFi4Dwd.png"/></item><item><title>一步步教你搭建哔哩哔哩直播机器人与积分商城，轻松部署从零开始</title><link>https://hejunjie.life/blog/b06795f9</link><guid isPermaLink="true">https://hejunjie.life/blog/b06795f9</guid><description>在这篇详细教程中，将教你如何从零开始部署哔哩哔哩直播机器人和积分商城。无论你是初学者还是开发者，都能轻松跟随步骤搭建自己的直播机器人，实现弹幕监控、自动答谢、定时广告等功能，并让观众通过消费获得积分选择礼物。提供从购买服务器到部署项目的完整指南，让你快速上手</description><pubDate>Sat, 28 Dec 2024 02:27:55 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;有感觉到文章很乱吗？虽然不知道你怎么想但反正我是有点感觉到了。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;不同时间出了好几个教程管理起来真的很麻烦。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;我把所有相关的内容放到了一个文档中：&lt;a href=&quot;/danmusuite&quot;&gt;点击前往&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1&gt;项目简介&lt;/h1&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/zxc7563598/php-bilibili-danmu&quot;&gt;bilibili-danmu&lt;/a&gt;不仅能够监控直播间的弹幕进行自动答谢和定时广告推送，还内置了一个积分商城.&lt;/p&gt;
&lt;p&gt;用户在直播间开通大航海即可获得积分，并使用积分兑换礼物。&lt;/p&gt;
&lt;p&gt;这使得它不仅仅是一个简单的机器人，更是一个可以提升观众互动和增加主播收益的全方位工具。&lt;/p&gt;
&lt;p&gt;GitHub 仓库地址：&lt;a href=&quot;https://github.com/zxc7563598/php-bilibili-danmu&quot;&gt;点击进入&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;视频搭建教程：&lt;a href=&quot;https://www.bilibili.com/video/BV1PBrSYxEQn&quot;&gt;点击查看&lt;/a&gt;&lt;/p&gt;
&lt;h1&gt;需要准备的工具&lt;/h1&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;阿里云服务器&lt;/strong&gt;：这是我们将要部署项目的基础环境，费用大约在每月 60 元左右。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;FinaShell&lt;/strong&gt;：一个简便的工具，帮助你连接服务器并进行管理。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Docker&lt;/strong&gt;：容器化技术，可以轻松部署和管理项目。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Git&lt;/strong&gt;：用于拉取项目代码，快速获取所需文件。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;在接下来的步骤中，我们将详细介绍如何购买服务器、安装必要的工具、部署项目并启动服务。如果你是第一次接触这些内容，也无需担心，本文将一步步为你解释每个细节。&lt;/p&gt;
&lt;h1&gt;部署说明&lt;/h1&gt;
&lt;h2&gt;1.购买服务器&lt;/h2&gt;
&lt;h3&gt;登录阿里云&lt;/h3&gt;
&lt;p&gt;前往 &lt;a href=&quot;https://home.console.aliyun.com&quot;&gt;阿里云控制台&lt;/a&gt;。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://hejunjie-blog.oss-rg-china-mainland.aliyuncs.com/b06795f9/00001.png#pic_center&quot; alt=&quot;可以通过支付宝/淘宝扫码登录&quot;&gt;&lt;/p&gt;
&lt;h3&gt;选择云服务器 ECS&lt;/h3&gt;
&lt;p&gt;进入左上角菜单，选择 &lt;a href=&quot;https://ecs.console.aliyun.com/home&quot;&gt;云服务器 ECS&lt;/a&gt;。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://hejunjie-blog.oss-rg-china-mainland.aliyuncs.com/b06795f9/00002.png#pic_center&quot; alt=&quot;可以通过支付宝/淘宝扫码登录&quot;&gt;&lt;/p&gt;
&lt;h3&gt;创建实例&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;https://hejunjie-blog.oss-rg-china-mainland.aliyuncs.com/b06795f9/00003.png#pic_center&quot; alt=&quot;点击创建实例&quot;&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;付费类型&lt;/strong&gt;：包年包月&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;首次使用可以选择 &lt;strong&gt;按量付费&lt;/strong&gt;，按量付费模式下服务器按小时收费，不需要服务器后释放即可，不需要过高的投入
长期使用建议包年包月，整体费用会更优惠些&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;地域&lt;/strong&gt;：选择香港。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;根据工信部规定，国内服务器必须进行备案，否则无法从事互联网信息服务，不过，由于中国设立有特别行政区，采用的法律法规和中国内地有所不同，如香港，使用香港服务器则不用备案&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;规格&lt;/strong&gt;：选择 2 核 2G 配置&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;不低于该配置的情况下可以随意购买，&lt;strong&gt;注意：尽量避免购买突发性能实例&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;系统&lt;/strong&gt;：选择 Ubuntu 24.04 64 位。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;网络&lt;/strong&gt;：分配 IPv4 地址，按流量付费，带宽速度拉满。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;按流量付费时，带宽速度不影响价格，因此访问网站的人不多的情况下可以以较少的费用获得足够速度的网络带宽&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;登录凭证&lt;/strong&gt;：选择自定义密码并设置。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;购买服务器后登录服务器的密码，建议妥善保管&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://hejunjie-blog.oss-rg-china-mainland.aliyuncs.com/b06795f9/00004.png#pic_center&quot; alt=&quot;实例购买页面&quot;&gt;&lt;/p&gt;
&lt;h3&gt;开放端口&lt;/h3&gt;
&lt;p&gt;在「云服务器 ECS -&gt; 实例 -&gt; 服务器 -&gt; 安全组」中开放 7777，5177 端口&lt;/p&gt;
&lt;p&gt;开启方式见下方视频：&lt;/p&gt;
&lt;p&gt;{% dplayer &quot;url=/video/b06795f9/output-13.mp4&quot; &quot;loop=no&quot; &quot;theme=#FADFA3&quot; &quot;autoplay=false&quot; &quot;screenshot=true&quot; &quot;hotkey=true&quot; &quot;preload=false&quot; &quot;volume=0.9&quot; &quot;playbackSpeed=1&quot; &quot;lang=zh-cn&quot; &quot;mutex=true&quot; %}&lt;/p&gt;
&lt;h3&gt;连接服务器&lt;/h3&gt;
&lt;p&gt;下载并安装 &lt;a href=&quot;https://www.hostbuf.com&quot;&gt;FinaShell&lt;/a&gt;（支持跨平台）。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://hejunjie-blog.oss-rg-china-mainland.aliyuncs.com/b06795f9/00005.png#pic_center&quot; alt=&quot;根据自己的系统安装对应的版本&quot;&gt;&lt;/p&gt;
&lt;p&gt;使用 FinaShell 输入服务器 IP 和密码完成连接。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://hejunjie-blog.oss-rg-china-mainland.aliyuncs.com/b06795f9/00006.png#pic_center&quot; alt=&quot;服务器公网IP可在阿里云云服务器ECS列表中查看&quot;&gt;&lt;/p&gt;
&lt;h2&gt;2.安装 docker&lt;/h2&gt;
&lt;h3&gt;获取最新的软件包信息&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo apt update
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;{% dplayer &quot;url=/video/b06795f9/output-1.mp4&quot; &quot;loop=no&quot; &quot;theme=#FADFA3&quot; &quot;autoplay=false&quot; &quot;screenshot=true&quot; &quot;hotkey=true&quot; &quot;preload=false&quot; &quot;volume=0.9&quot; &quot;playbackSpeed=1&quot; &quot;lang=zh-cn&quot; &quot;mutex=true&quot; %}&lt;/p&gt;
&lt;h3&gt;安装所有可用的更新&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo apt upgrade -y
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;{% dplayer &quot;url=/video/b06795f9/output-2.mp4&quot; &quot;loop=no&quot; &quot;theme=#FADFA3&quot; &quot;autoplay=false&quot; &quot;screenshot=true&quot; &quot;hotkey=true&quot; &quot;preload=false&quot; &quot;volume=0.9&quot; &quot;playbackSpeed=1&quot; &quot;lang=zh-cn&quot; &quot;mutex=true&quot; %}&lt;/p&gt;
&lt;h3&gt;安装 Docker 需要一些必需的依赖包&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo apt install apt-transport-https ca-certificates curl software-properties-common
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;{% dplayer &quot;url=/video/b06795f9/output-3.mp4&quot; &quot;loop=no&quot; &quot;theme=#FADFA3&quot; &quot;autoplay=false&quot; &quot;screenshot=true&quot; &quot;hotkey=true&quot; &quot;preload=false&quot; &quot;volume=0.9&quot; &quot;playbackSpeed=1&quot; &quot;lang=zh-cn&quot; &quot;mutex=true&quot; %}&lt;/p&gt;
&lt;h3&gt;添加 Docker 官方的 GPG 密钥&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;{% dplayer &quot;url=/video/b06795f9/output-4.mp4&quot; &quot;loop=no&quot; &quot;theme=#FADFA3&quot; &quot;autoplay=false&quot; &quot;screenshot=true&quot; &quot;hotkey=true&quot; &quot;preload=false&quot; &quot;volume=0.9&quot; &quot;playbackSpeed=1&quot; &quot;lang=zh-cn&quot; &quot;mutex=true&quot; %}&lt;/p&gt;
&lt;h3&gt;将 Docker 官方的仓库添加到 APT 源中&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;echo &quot;deb [arch=amd64 signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable&quot; | sudo tee /etc/apt/sources.list.d/docker.list &gt; /dev/null
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;{% dplayer &quot;url=/video/b06795f9/output-5.mp4&quot; &quot;loop=no&quot; &quot;theme=#FADFA3&quot; &quot;autoplay=false&quot; &quot;screenshot=true&quot; &quot;hotkey=true&quot; &quot;preload=false&quot; &quot;volume=0.9&quot; &quot;playbackSpeed=1&quot; &quot;lang=zh-cn&quot; &quot;mutex=true&quot; %}&lt;/p&gt;
&lt;h3&gt;更新本地包索引。&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo apt update
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;{% dplayer &quot;url=/video/b06795f9/output-6.mp4&quot; &quot;loop=no&quot; &quot;theme=#FADFA3&quot; &quot;autoplay=false&quot; &quot;screenshot=true&quot; &quot;hotkey=true&quot; &quot;preload=false&quot; &quot;volume=0.9&quot; &quot;playbackSpeed=1&quot; &quot;lang=zh-cn&quot; &quot;mutex=true&quot; %}&lt;/p&gt;
&lt;h3&gt;安装 Docker&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo apt install docker-ce docker-ce-cli containerd.io docker-compose
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;{% dplayer &quot;url=/video/b06795f9/output-7.mp4&quot; &quot;loop=no&quot; &quot;theme=#FADFA3&quot; &quot;autoplay=false&quot; &quot;screenshot=true&quot; &quot;hotkey=true&quot; &quot;preload=false&quot; &quot;volume=0.9&quot; &quot;playbackSpeed=1&quot; &quot;lang=zh-cn&quot; &quot;mutex=true&quot; %}&lt;/p&gt;
&lt;h3&gt;确保 Docker 服务正在运行&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo systemctl start docker
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;{% dplayer &quot;url=/video/b06795f9/output-8.mp4&quot; &quot;loop=no&quot; &quot;theme=#FADFA3&quot; &quot;autoplay=false&quot; &quot;screenshot=true&quot; &quot;hotkey=true&quot; &quot;preload=false&quot; &quot;volume=0.9&quot; &quot;playbackSpeed=1&quot; &quot;lang=zh-cn&quot; &quot;mutex=true&quot; %}&lt;/p&gt;
&lt;h3&gt;设置 Docker 开机自启&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo systemctl enable docker
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;{% dplayer &quot;url=/video/b06795f9/output-9.mp4&quot; &quot;loop=no&quot; &quot;theme=#FADFA3&quot; &quot;autoplay=false&quot; &quot;screenshot=true&quot; &quot;hotkey=true&quot; &quot;preload=false&quot; &quot;volume=0.9&quot; &quot;playbackSpeed=1&quot; &quot;lang=zh-cn&quot; &quot;mutex=true&quot; %}&lt;/p&gt;
&lt;h3&gt;你可以通过运行以下命令来验证 Docker 是否已正确安装：&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo docker --version
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;输出应该显示类似以下的版本信息&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Docker version 27.4.1, build b9d17ea&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;3.部署项目&lt;/h2&gt;
&lt;h3&gt;克隆项目&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git clone https://github.com/zxc7563598/php-bilibili-danmu-docker.git /opt/bilibili-robots
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;{% dplayer &quot;url=/video/b06795f9/output-10.mp4&quot; &quot;loop=no&quot; &quot;theme=#FADFA3&quot; &quot;autoplay=false&quot; &quot;screenshot=true&quot; &quot;hotkey=true&quot; &quot;preload=false&quot; &quot;volume=0.9&quot; &quot;playbackSpeed=1&quot; &quot;lang=zh-cn&quot; &quot;mutex=true&quot; %}&lt;/p&gt;
&lt;h3&gt;前往目录&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cd /opt/bilibili-robots
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;{% dplayer &quot;url=/video/b06795f9/output-11.mp4&quot; &quot;loop=no&quot; &quot;theme=#FADFA3&quot; &quot;autoplay=false&quot; &quot;screenshot=true&quot; &quot;hotkey=true&quot; &quot;preload=false&quot; &quot;volume=0.9&quot; &quot;playbackSpeed=1&quot; &quot;lang=zh-cn&quot; &quot;mutex=true&quot; %}&lt;/p&gt;
&lt;h3&gt;执行初始化脚本&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sh ./setup.sh
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;{% dplayer &quot;url=/video/b06795f9/output-12.mp4&quot; &quot;loop=no&quot; &quot;theme=#FADFA3&quot; &quot;autoplay=false&quot; &quot;screenshot=true&quot; &quot;hotkey=true&quot; &quot;preload=false&quot; &quot;volume=0.9&quot; &quot;playbackSpeed=1&quot; &quot;lang=zh-cn&quot; &quot;mutex=true&quot; %}&lt;/p&gt;
&lt;h3&gt;构建 docker&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;docker-compose build
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;启动 docker&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;docker-compose up -d
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://hejunjie-blog.oss-rg-china-mainland.aliyuncs.com/b06795f9/00007.png#pic_center&quot; alt=&quot;当出现以下内容时则部署完成！&quot;&gt;&lt;/p&gt;
&lt;h1&gt;正常使用&lt;/h1&gt;
&lt;p&gt;机器人的地址为：http://您服务器 IP:7777&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;首次登录会要求需要输入账号密码，输入后后续访问均需要通过本次输入的账号密码进行访问，请妥善保存&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;启用积分商城&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;前往机器人后台 &lt;code&gt;http://您服务器IP:7777&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;进入积分商城&lt;/li&gt;
&lt;li&gt;进入系统配置&lt;/li&gt;
&lt;li&gt;点击 &lt;strong&gt;构建商城&lt;/strong&gt; 获取商城地址&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;{% dplayer &quot;url=/video/b06795f9/output-14.mp4&quot; &quot;loop=no&quot; &quot;theme=#FADFA3&quot; &quot;autoplay=false&quot; &quot;screenshot=true&quot; &quot;hotkey=true&quot; &quot;preload=false&quot; &quot;volume=0.9&quot; &quot;playbackSpeed=1&quot; &quot;lang=zh-cn&quot; &quot;mutex=true&quot; %}&lt;/p&gt;
&lt;h1&gt;常见问题&lt;/h1&gt;
&lt;h2&gt;为什么要购买香港的服务器？&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;docker&lt;/strong&gt; 的环境，&lt;strong&gt;docker&lt;/strong&gt; 安装后的 &lt;strong&gt;镜像&lt;/strong&gt; 以及 &lt;strong&gt;项目&lt;/strong&gt; 均托管在外网，使用国内地区的服务器时需要解决科学上网的问题&lt;/li&gt;
&lt;li&gt;根据工信部规定，国内服务器必须进行备案，否则无法从事互联网信息服务，使用国内地区的服务器需要绑定域名时要对域名进行备案&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;使用香港服务器会出现哪些问题？&lt;/h2&gt;
&lt;p&gt;阿里云提供的香港服务器有独立回国的线路，因此基本不会存在问题&lt;/p&gt;
&lt;p&gt;但偶尔也会存在部分用户访问不到的情况，通常片刻后恢复正常，只不过相对国内服务器还是存在一点稳定性差异&lt;/p&gt;
&lt;h2&gt;如何绑定域名？&lt;/h2&gt;
&lt;p&gt;在 &lt;a href=&quot;https://dc.console.aliyun.com&quot;&gt;阿里云&lt;/a&gt; 或 &lt;a href=&quot;https://www.godaddy.com&quot;&gt;Godaddy&lt;/a&gt; 等域名交易平台可以 &lt;strong&gt;注册/购买&lt;/strong&gt; 域名。&lt;/p&gt;
&lt;p&gt;域名购买，解析到您开通的服务器 IP 后，通过 Finalshell 连接服务器后执行以下内容：&lt;/p&gt;
&lt;p&gt;1.获取最新的软件包信息&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo apt update
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;2.安装 nginx&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo apt install nginx
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;3.在 &lt;strong&gt;/etc/nginx/conf.d&lt;/strong&gt; 目录创建一个.conf 文件（比如域名是 &lt;code&gt;xxx.com&lt;/code&gt; 配置文件就叫 &lt;code&gt;xxx.com.conf&lt;/code&gt;）文件内容如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;server {
    listen 80;
    server_name 机器人控制台域名;

    location / {
        proxy_pass http://localhost:7777;
        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_set_header X-Forwarded-Proto $scheme;
    }
}
server {
    listen 80;
    server_name 积分商城域名;

    location / {
        proxy_pass http://localhost:5177;
        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_set_header X-Forwarded-Proto $scheme;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;4.检查 nginx 配置是否正确&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo nginx -t
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;5.重启 nginx&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo systemctl reload nginx
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;6.安装 certbot&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo apt install certbot python3-certbot-nginx
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;7.申请配置证书&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo certbot --nginx -d 域名
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;8.修改配置
修改 &lt;strong&gt;项目地址&lt;/strong&gt; 与 &lt;strong&gt;商城地址&lt;/strong&gt; 后下拉到底部进行保存&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://hejunjie-blog.oss-rg-china-mainland.aliyuncs.com/b06795f9/00008.png#pic_center&quot; alt=&quot;控制台 -&gt; 积分商城 -&gt; 系统配置&quot;&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;暂时想不到其他问题了，想起来再说&lt;/p&gt;
&lt;/blockquote&gt;</content:encoded><h:img src="/_astro/bilibili.p76c9pFG.jpg"/><enclosure url="/_astro/bilibili.p76c9pFG.jpg"/></item><item><title>用 Python 批量插入图片到 Word 文档 —— 解放双手的利器</title><link>https://hejunjie.life/blog/cb4edc93</link><guid isPermaLink="true">https://hejunjie.life/blog/cb4edc93</guid><description>使用 Python 脚本批量将图片插入 Word 文档，解决手动排版的烦恼。脚本实现每行 3 张图片，自动分页，适合整理大批量截图、报告生成等需求。代码简单实用，高效提升工作效率</description><pubDate>Tue, 10 Dec 2024 07:24:45 GMT</pubDate><content:encoded>&lt;p&gt;最近在整理一堆截图资料，需要把几万张截图整理到 Word 文档中打印，实在是让人头大。&lt;/p&gt;
&lt;p&gt;这些截图每行放 3 张，每页 6 张，这要是创建表格然后一张一张往里面放的话大概弄到猴年马月都搞不完。&lt;/p&gt;
&lt;p&gt;于是决定写个小脚本来自动化处理这件事，以便节省时间摸鱼。&lt;/p&gt;
&lt;p&gt;于是就有了这个小脚本，简单易懂，关键是能用！废话不多说，直接上代码，接着再讲讲实现逻辑。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from docx import Document
from docx.shared import Inches
import os

# 图片文件夹路径
img_folder = &quot;/user/path&quot;  # 换成你存图片的路径
img_files = sorted([f for f in os.listdir(img_folder) if f.endswith((&apos;.jpg&apos;, &apos;.png&apos;))])

# 创建 Word 文档
doc = Document()

# 设置图片宽度（单位是英寸）
image_width = 4.7 / 2.54  # 把 4.7 厘米转换成英寸，Word里是用英寸单位的

# 每行插入 3 张图片
images_per_row = 3

# 当前插入图片的计数器
count = 0

# 循环插入图片
for i, img in enumerate(img_files):
    # 如果是新的一行，就创建一行表格
    if count % images_per_row == 0:
        row = doc.add_table(rows=1, cols=images_per_row).rows[0]

    # 插入图片到当前表格单元格
    cell = row.cells[count % images_per_row]
    run = cell.paragraphs[0].add_run()
    run.add_picture(os.path.join(img_folder, img), width=Inches(image_width))

    # 图片计数器加 1
    count += 1

    # 每插入 6 张图片，就分页
    if (i + 1) % 6 == 0:
        doc.add_page_break()

# 保存文档
output_path = &quot;/user/path/name.docx&quot;  # 换成你的保存路径
doc.save(output_path)

print(f&quot;文档已生成：{output_path}&quot;)

&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;脚本逻辑&lt;/h2&gt;
&lt;p&gt;整个脚本可以简单分成几个步骤：&lt;/p&gt;
&lt;h3&gt;准备好图片&lt;/h3&gt;
&lt;p&gt;先把需要处理的图片放到一个文件夹里，比如 /user/path。脚本会扫描这个文件夹，只找扩展名是 .jpg 或 .png 的图片，还会自动按文件名排序，方便后续插入时保持顺序。&lt;/p&gt;
&lt;h3&gt;创建 Word 文档&lt;/h3&gt;
&lt;p&gt;我们用 &lt;strong&gt;python-docx&lt;/strong&gt; 库来处理 Word 文档，开始时是空白的，后续所有内容都是动态插入的。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;通过 &lt;code&gt;pip install python-docx&lt;/code&gt; 安装库&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;循环插入图片&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;表格布局&lt;/strong&gt;：每行 3 张图片，用表格实现排版，每张图片放在一个表格单元格里。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;图片宽度&lt;/strong&gt;：为保持美观，设置每张图片的宽度为 4.7 厘米（脚本里转成了英寸）。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;分页&lt;/strong&gt;：每插入 6 张图片，自动插入一个分页符，让文档看起来更加规整。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;保存文档&lt;/h3&gt;
&lt;p&gt;生成的 Word 文件会保存到指定路径，直接打开就能看到排版整齐的图片文档啦！&lt;/p&gt;
&lt;h2&gt;效果展示&lt;/h2&gt;
&lt;p&gt;完成后的文档每行有 3 张图片，整齐美观。6 张图片一页，自动分页，特别适合整理大批量截图。以下是文档排版的示意：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;---

## | 图 1 | 图 2 | 图 3 |

## | 图 4 | 图 5 | 图 6 |

## （分页）

## | 图 7 | 图 8 | 图 9 |
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;为什么要用这个脚本？&lt;/h2&gt;
&lt;p&gt;我之前也尝试过手动插入，但拖来拖去太费劲，还经常对不齐。有了这个脚本，直接运行一下，几万张图片轻松搞定。更重要的是，万一后续需求变了，比如每行插 4 张、分页规则不同，只需要稍微改动代码就行，效率提升不是一点点！&lt;/p&gt;</content:encoded><h:img src="/_astro/python.DaFi4Dwd.png"/><enclosure url="/_astro/python.DaFi4Dwd.png"/></item><item><title>从零开始：如何创建和启动 Docker 容器</title><link>https://hejunjie.life/blog/26ee1ee8</link><guid isPermaLink="true">https://hejunjie.life/blog/26ee1ee8</guid><description>本文将带你一步步从零开始创建 Docker 配置文件，讲解如何编写 Dockerfile 和 docker-compose.yml，以及如何使用 Docker 启动和运行容器。无论你是 Docker 新手还是开发者，本教程都将帮助你快速掌握 Docker 的基础知识，并成功运行你的应用</description><pubDate>Sun, 01 Dec 2024 10:10:57 GMT</pubDate><content:encoded>&lt;p&gt;最近我做了一个 &lt;a href=&quot;https://github.com/zxc7563598/php-bilibili-danmu&quot;&gt;哔哩哔哩直播弹幕姬&lt;/a&gt; 项目，为了让大家能更方便地使用，我决定将这个项目容器化，直接用 Docker 一键启动。这样，别人就不用担心配置环境了，直接运行就好。&lt;/p&gt;
&lt;p&gt;如果你对 Docker 还不太熟悉，它其实就是一个让你把应用和所有的依赖打包在一起的工具，确保在任何机器上都能以相同的方式运行。通过 Docker，我能把项目打包成容器，避免了不同环境配置不一致的问题。&lt;/p&gt;
&lt;p&gt;接下来，我会一步步带你走过：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;如何写 Dockerfile 来创建镜像；&lt;/li&gt;
&lt;li&gt;用 docker-compose.yml 来管理多个容器；&lt;/li&gt;
&lt;li&gt;怎么构建并启动 Docker 容器；&lt;/li&gt;
&lt;li&gt;容器里的应用怎么配置和运行；&lt;/li&gt;
&lt;li&gt;如果需要扩展容器环境，应该怎么做。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;不管你是刚接触 Docker 还是已经有点经验，相信这篇教程能帮你更好地理解 Docker，并能把自己的项目轻松容器化。&lt;/p&gt;
&lt;h2&gt;创建 Dockerfile&lt;/h2&gt;
&lt;p&gt;Dockerfile 是一个文本文件，包含了一系列的指令，Docker 使用这些指令来构建镜像。你可以通过它指定操作系统、安装依赖、复制文件、设置环境变量等。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 Dockerfile&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dockerfile&quot;&gt;FROM php:8.2-cli-alpine

# 安装 Composer
COPY --from=composer:2.7 /usr/bin/composer /usr/bin/composer

# 设置环境变量
ENV TZ=Asia/Shanghai

# 更新软件包列表并安装系统依赖
RUN apk update &amp;#x26;&amp;#x26; apk add --no-cache \
    bash \
    git \
    curl \
    brotli \
    build-base \
    autoconf \
    libtool \
    make \
    linux-headers \
    pcre-dev \
    libevent \
    libevent-dev \
    libpng-dev \
    libjpeg-turbo-dev \
    libwebp-dev \
    freetype-dev \
    $PHPIZE_DEPS \
    redis \
    busybox-suid  # 添加 cron 支持

# 安装 PHP 扩展
RUN docker-php-ext-install pdo_mysql pcntl

# 安装 PHP GD 扩展
RUN docker-php-ext-configure gd \
    --with-jpeg \
    --with-webp \
    --with-freetype \
    &amp;#x26;&amp;#x26; docker-php-ext-install gd

# 安装 PHP Redis 扩展
RUN pecl install redis \
    &amp;#x26;&amp;#x26; docker-php-ext-enable redis

# 安装 PHP Brotli 扩展
RUN pecl install brotli \
    &amp;#x26;&amp;#x26; docker-php-ext-enable brotli

# 设置工作目录
WORKDIR /var/www/bilibili_danmu

# 复制项目文件到容器中
RUN git clone https://github.com/zxc7563598/php-bilibili-danmu.git /var/www/bilibili_danmu

# 安装 PHP 依赖（生产环境中使用 --no-dev）
RUN composer install

# 设置目录权限
RUN chmod +x /var/www/bilibili_danmu

# 添加 cron 任务
RUN echo &quot;0 * * * * /var/www/bilibili_danmu/check_and_update.sh&quot; &gt; /etc/crontabs/root

&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;说明：
&lt;strong&gt;FROM&lt;/strong&gt;：指定基础镜像。
&lt;strong&gt;RUN&lt;/strong&gt;：安装依赖和 PHP 扩展。
&lt;strong&gt;COPY&lt;/strong&gt;：将本地文件复制到容器中。
&lt;strong&gt;WORKDIR&lt;/strong&gt;：设置工作目录。
&lt;strong&gt;CMD&lt;/strong&gt;：容器启动时执行的命令。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;创建 Docker Compose 文件&lt;/h2&gt;
&lt;p&gt;docker-compose.yml 文件定义了多个容器的服务、网络和卷。它让我们可以一键启动多个容器，而不需要手动启动每个容器。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 docker-compose.yml&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yml&quot;&gt;services:
  php:
    build: .
    container_name: php
    command: sh -c &quot;sh setup.sh &amp;#x26;&amp;#x26; php start.php start -d &amp;#x26;&amp;#x26; crond &amp;#x26;&amp;#x26; tail -f /dev/null&quot;
    volumes:
      - /var/www/bilibili_danmu
    networks:
      - webnet
    expose:
      - &apos;7776&apos;

  nginx:
    image: nginx:latest
    container_name: nginx
    volumes:
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf
      - .:/var/www/bilibili_danmu
    ports:
      - &apos;7777:80&apos;
    networks:
      - webnet
    depends_on:
      - php

  redis:
    image: redis:latest
    container_name: redis
    ports:
      - &apos;6379&apos;
    networks:
      - webnet

networks:
  webnet:
    driver: bridge
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在上述文件中，定义了三个服务：php、nginx 和 redis，并配置了它们如何协同工作：&lt;/p&gt;
&lt;p&gt;PHP 服务：运行应用代码并启动 PHP 和 cron 服务。&lt;/p&gt;
&lt;p&gt;Nginx 服务：提供 Web 访问，使用自定义配置文件。&lt;/p&gt;
&lt;p&gt;Redis 服务：提供缓存支持。&lt;/p&gt;
&lt;p&gt;所有这些服务都连接到同一个自定义网络 webnet，确保它们能够顺畅地通信。&lt;/p&gt;
&lt;p&gt;可以通过运行 docker-compose up -d 来启动这些服务，并在浏览器中访问 Nginx 提供的 Web 服务。&lt;/p&gt;
&lt;h3&gt;PHP 服务&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yml&quot;&gt;php:
  build: .
  container_name: php
  command: sh -c &quot;sh setup.sh &amp;#x26;&amp;#x26; php start.php start -d &amp;#x26;&amp;#x26; crond &amp;#x26;&amp;#x26; tail -f /dev/null&quot;
  volumes:
    - /var/www/bilibili_danmu
  networks:
    - webnet
  expose:
    - &apos;7776&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;build: .：这行代码表示 Docker 会在当前目录下查找 &lt;strong&gt;Dockerfile&lt;/strong&gt; 并基于它构建 PHP 镜像。这个镜像包含了运行 PHP 应用所需的所有依赖和配置。&lt;/li&gt;
&lt;li&gt;container_name: php：为这个容器指定一个名字，便于我们在 Docker 中识别和管理。&lt;/li&gt;
&lt;li&gt;command: sh -c &quot;sh setup.sh &amp;#x26;&amp;#x26; php start.php start -d &amp;#x26;&amp;#x26; crond &amp;#x26;&amp;#x26; tail -f /dev/null&quot;：这行命令会在容器启动时执行。首先，它会运行 setup.sh 脚本，接着启动 PHP 应用（start.php start -d），然后启动 cron 服务（crond），最后使用 tail -f /dev/null 保持容器处于运行状态，防止容器因任务执行完毕而停止。&lt;/li&gt;
&lt;li&gt;volumes：&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-yml&quot;&gt;volumes:
  - /var/www/bilibili_danmu
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这行代码将宿主机的 /var/www/bilibili_danmu 目录挂载到容器内部的 /var/www/bilibili_danmu 目录。这确保了 PHP 容器能够访问和处理项目代码。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;networks：&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-yml&quot;&gt;networks:
  - webnet
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;容器会连接到一个名为 &lt;strong&gt;webnet&lt;/strong&gt; 的网络，这个网络在文件底部定义。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;expose：&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-yml&quot;&gt;expose:
  - &apos;7776&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;expose&lt;/strong&gt; 命令暴露容器的 7776 端口，供其他容器（如 Nginx）访问。需要注意的是，expose 并不会将端口映射到宿主机，只是让其他容器可以通过这个端口访问。&lt;/p&gt;
&lt;h3&gt;Nginx 服务&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yml&quot;&gt;nginx:
  image: nginx:latest
  container_name: nginx
  volumes:
    - ./nginx/default.conf:/etc/nginx/conf.d/default.conf
    - .:/var/www/bilibili_danmu
  ports:
    - &apos;7777:80&apos;
  networks:
    - webnet
  depends_on:
    - php
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;image: nginx:latest：
使用官方的 Nginx 镜像，并始终拉取最新版本。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;container_name: nginx：&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;为 Nginx 容器指定一个名字，便于识别。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;volumes：&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-yml&quot;&gt;volumes:
  - ./nginx/default.conf:/etc/nginx/conf.d/default.conf
  - .:/var/www/bilibili_danmu
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这两个挂载使得宿主机的 &lt;strong&gt;./nginx/default.conf&lt;/strong&gt; 配置文件和整个项目目录能够映射到容器内。这样，Nginx 容器就能使用项目的代码和自定义的配置文件。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;ports：&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-yml&quot;&gt;ports:
  - &apos;7777:80&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这行代码将容器的 80 端口映射到宿主机的 7777 端口，允许外部访问 Nginx 服务。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;networks：&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-yml&quot;&gt;networks:
  - webnet
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Nginx 服务也连接到 &lt;strong&gt;webnet&lt;/strong&gt; 网络，以便和其他容器通信。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;depends_on：&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-yml&quot;&gt;depends_on:
  - php
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这表示 Nginx 容器在启动时依赖于 &lt;strong&gt;php&lt;/strong&gt; 容器。&lt;strong&gt;depends_on&lt;/strong&gt; 确保 PHP 容器先启动，以便 Nginx 容器能够访问它。&lt;/p&gt;
&lt;h3&gt;Redis 服务&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yml&quot;&gt;redis:
  image: redis:latest
  container_name: redis
  ports:
    - &apos;6379&apos;
  networks:
    - webnet
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;image: redis:latest：
使用官方 Redis 镜像，拉取最新版本。&lt;/p&gt;
&lt;p&gt;container_name: redis：
为 Redis 容器指定一个名字，便于管理和识别。&lt;/p&gt;
&lt;p&gt;ports：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yml&quot;&gt;ports:
  - &apos;6379&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;将 Redis 的默认端口 6379 暴露到宿主机上，允许外部程序与 Redis 通信。&lt;/p&gt;
&lt;p&gt;networks：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yml&quot;&gt;networks:
  - webnet
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Redis 容器连接到 &lt;strong&gt;webnet&lt;/strong&gt; 网络，确保它与 PHP 和 Nginx 容器能够通信。&lt;/p&gt;
&lt;h3&gt;自定义网络定义&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yml&quot;&gt;networks:
  webnet:
    driver: bridge
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;networks 定义了一个名为 &lt;strong&gt;webnet&lt;/strong&gt; 的网络，所有容器都将连接到这个网络。通过这种方式，容器间可以互相通信，并且不会暴露给外部世界，除非明确映射端口。这里使用了 bridge 网络驱动，这是 Docker 默认的网络类型，适合容器之间的通信。&lt;/p&gt;
&lt;h2&gt;构建和启动容器&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;构建 Docker 镜像： 在包含 Dockerfile 与 docker-compose.yml 的目录下运行以下命令构建镜像：&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-shell&quot;&gt;docker-compose build
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;启动 Docker Compose 服务： 在包含 Dockerfile 与 docker-compose.yml 的目录下运行：&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-shell&quot;&gt;docker-compose up -d
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这条命令会构建和启动所有定义的服务。-d 参数表示在后台运行。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;查看容器状态： 使用以下命令查看运行中的容器：&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-shell&quot;&gt;docker ps
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;停止容器： 要停止容器，运行以下命令：&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-shell&quot;&gt;docker-compose down
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;其他常用命令&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;查看容器日志：&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;docker logs &amp;#x3C;container_name_or_id&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;进入容器的终端：&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;docker exec -it &amp;#x3C;container_name_or_id&gt; sh
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;删除容器和镜像：
停止并删除所有容器和镜像：&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;docker-compose down --rmi all
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;删除不再使用的资源&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;以下内容均会被删除：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;所有未被任何容器使用的镜像（dangling images）&lt;/li&gt;
&lt;li&gt;所有停止的容器（stopped containers）&lt;/li&gt;
&lt;li&gt;所有未被使用的网络（unused networks）&lt;/li&gt;
&lt;li&gt;所有未被使用的卷（未使用的匿名卷，需加 --volumes 才会删除）&lt;/li&gt;
&lt;li&gt;所有未使用的构建缓存&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;docker system prune
&lt;/code&gt;&lt;/pre&gt;</content:encoded><h:img src="/_astro/docker.BMGTOMFO.png"/><enclosure url="/_astro/docker.BMGTOMFO.png"/></item><item><title>从手动到自动：使用 Puppeteer 高效截取大量网页截图</title><link>https://hejunjie.life/blog/e7de3872</link><guid isPermaLink="true">https://hejunjie.life/blog/e7de3872</guid><description>了解如何使用 Puppeteer 实现大量网页截图的自动化，从初步安装到代码实现，帮助你快速应对繁琐的截图任务</description><pubDate>Sun, 03 Nov 2024 02:15:38 GMT</pubDate><content:encoded>&lt;p&gt;最近生活出了那么一点小问题。给老板做了套系统，老板拿着去做小额贷款犯法了。所以我也是被警方控制协助调查，这下真从面向对象变成面向监狱了。不过还好，因为积极配合，问题不大。&lt;/p&gt;
&lt;p&gt;但事情远未结束。警方要求我取证，把所有后台页面都截成图。零零散散算下来，大约有 20 万张截图。这要是手动来得干几个月都说不定。于是，我开始寻求能救命的自动化工具，这时，我发现了 Puppeteer。&lt;/p&gt;
&lt;h1&gt;发现 Puppeteer&lt;/h1&gt;
&lt;p&gt;Puppeteer 是一个由 Google 开发的 Node.js 库，它为控制无头浏览器（如 Chromium）提供了简单的 API。它不仅支持网页截图，还可以执行各种其他任务，比如网页爬虫、自动化表单提交和生成 PDF 等。&lt;/p&gt;
&lt;h1&gt;Puppeteer 入门&lt;/h1&gt;
&lt;p&gt;要开始使用 Puppeteer，只需几个简单的步骤：&lt;/p&gt;
&lt;h2&gt;安装 Puppeteer：&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;    npm install puppeteer
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;编写基础代码&lt;/h2&gt;
&lt;p&gt;下面是一个基础的 Puppeteer 脚本，用于打开一个网页并截取截图：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const puppeteer = require(&apos;puppeteer&apos;)

;(async () =&gt; {
  const browser = await puppeteer.launch()
  const page = await browser.newPage()
  await page.goto(&apos;https://example.com&apos;)
  await page.screenshot({ path: &apos;example.png&apos; })
  await browser.close()
})()
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;运行这个脚本后，你将在项目目录中看到访问 &lt;strong&gt;https://example.com&lt;/strong&gt; 后截取到的截图文件 &lt;strong&gt;example.png&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1&gt;最终实现&lt;/h1&gt;
&lt;ul&gt;
&lt;li&gt;使用 Puppeteer 启动一个无头浏览器来打开特定 URL，并针对不同的 tag 进行多次抓取，每个抓取过程限速 5 个并行任务&lt;/li&gt;
&lt;li&gt;为每个 data 项创建一个路径并将截图保存到该路径下。&lt;/li&gt;
&lt;li&gt;为不同的 tag 设定了不同的加载延迟时间。&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const puppeteer = require(&apos;puppeteer&apos;);
const fs = require(&apos;fs&apos;);
const path = require(&apos;path&apos;);

(async () =&gt; {
    const pLimit = (await import(&apos;p-limit&apos;)).default;
    const limit = pLimit(5);
    const browser = await puppeteer.launch({
        args: [&apos;--no-sandbox&apos;, &apos;--disable-setuid-sandbox&apos;]
    });
    // 要抓取的页面参数信息
    const data = [
        { id: 16212, path: &quot;对应文件夹&quot; },
        { id: 16229, path: &quot;对应文件夹&quot; },
        { id: 16591, path: &quot;对应文件夹&quot; }
    ];
    // 要抓取的页面标签
    const tags = [
        &apos;customer&apos;,
        &apos;contact&apos;,
        &apos;address_book&apos;,
        &apos;sms&apos;,
        &apos;wind_control&apos;,
        &apos;orders&apos;
    ];
    // 网页要设置的cookie
    const cookies = [
        { name: &apos;token&apos;, value: &apos;xxxxxx&apos;, domain: &apos;&apos; },
    ];
    // 每个页面标签延迟抓取的毫秒时间
    const delays = {
        customer: 3000,
        contact: 10000,
        address_book: 3000,
        sms: 5000,
        wind_control: 2000,
        orders: 2000
    };
    // 开始处理
    for (const item of data) {
        const dirPath = path.join(__dirname, &apos;项目名称&apos;, item.path);
        if (!fs.existsSync(dirPath)) {
            fs.mkdirSync(dirPath, { recursive: true });
        }

        const screenshotPromises = tags.map(tag =&gt;
            limit(async () =&gt; {
                const url = `http://baseurl/${tag}?borrow_id=${item.id}`;
                const page = await browser.newPage();
                try {
                    // 设置截图的分辨率
                    await page.setViewport({ width: 1920, height: 1080 });
                    // 设置cookie
                    await page.setCookie(...cookies);
                    // 设置导航到指定 url 并等待页面加载完成，直到满足特定的网络状态条件
                    // 通常为：至少有 500 毫秒的时间内，网络连接不超过 2 个
                    await page.goto(url, { waitUntil: &apos;networkidle2&apos; });
                    // 对应标签暂停一段时间，以便网页动画加载完成
                    await page.waitForTimeout(delays[tag]);
                    // 拼接存储路径
                    const screenshotPath = path.join(dirPath, ${tag}.png);
                    // 存储截图
                    await page.screenshot({ path: screenshotPath });
                    console.log(`${screenshotPath}:存储成功`);
                } catch (error) {
                    console.error(`${screenshotPath}:存储失败`, error);
                } finally {
                    // 关闭页面
                    await page.close();
                }
            })
        );

        await Promise.all(screenshotPromises);
    }
    // 关闭浏览器
    await browser.close();
})();
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;结论&lt;/h1&gt;
&lt;p&gt;使用 Puppeteer 后，曾经需要手动完成的长时间截图任务现在只需短短几分钟或几小时就能搞定。如果你也有类似需求，试试 Puppeteer，相信它会成为你高效工作的利器。&lt;/p&gt;</content:encoded><h:img src="/_astro/nodejs.BxYklmtb.png"/><enclosure url="/_astro/nodejs.BxYklmtb.png"/></item><item><title>Bilibili直播信息流：连接方法与数据解析</title><link>https://hejunjie.life/blog/e1ccd148</link><guid isPermaLink="true">https://hejunjie.life/blog/e1ccd148</guid><description>本文详细介绍了自行实现B站直播WebSocket连接的完整流程。解析了基于WebSocket的应用层协议结构，涵盖认证包构建、心跳机制维护及数据包解析步骤，为开发者定制直播数据监控提供了完整技术方案</description><pubDate>Tue, 29 Oct 2024 03:26:34 GMT</pubDate><content:encoded>&lt;p&gt;如今，市面上已经有不少开源项目可以用于连接 B 站直播 WebSocket 获取信息流。&lt;/p&gt;
&lt;p&gt;但在实际使用中，常常发现它们并不能完全满足个性化需求。&lt;/p&gt;
&lt;p&gt;为了更好地适配自己的业务场景，我决定自己动手实现一套连接方案。&lt;/p&gt;
&lt;p&gt;因此，我整理了整个实现过程的一些关键步骤和注意事项，希望能够对有相似需求的朋友们有所帮助&lt;/p&gt;
&lt;h1&gt;接入前准备&lt;/h1&gt;
&lt;h2&gt;获取直播间真实 ID&lt;/h2&gt;
&lt;p&gt;网页版 直播间 URL 携带的房间号可能是短号，并不保证一定为真实地址，因此建议调用该接口以确保房间号无误&lt;/p&gt;
&lt;hr&gt;
&lt;blockquote&gt;
&lt;p&gt;请求方式：&lt;strong&gt;GET&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;请求地址：https://api.live.bilibili.com/room/v1/Room/get_info&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;| 参数名  | 类型 | 内容      |
| :------ | :--- | :-------- |
| room_id | int  | 直播间 ID |&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;请求响应示例与字段说明&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;curl -G &apos;https://api.live.bilibili.com/room/v1/Room/get_info&apos; \
--data-urlencode &apos;room_id=27668995&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;code&quot;: 0,
  &quot;msg&quot;: &quot;ok&quot;,
  &quot;message&quot;: &quot;ok&quot;,
  &quot;data&quot;: {
    &quot;uid&quot;: 3493124609411229, // 主播id
    &quot;room_id&quot;: 27668995, // 真实房间id
    &quot;short_id&quot;: 0, // 直播间短号，0为无短号
    &quot;attention&quot;: 13353, // 关注数量
    &quot;online&quot;: 4173, // 观看人数
    &quot;is_portrait&quot;: false, // 是否是竖屏
    &quot;description&quot;: &quot;一只屑狐狸，不专业的CV，新晋歌杂，不缺席你的每一天...直播时间： 晚22:00-02:00，下午3:00-7:00闪现打游戏&quot;, // 描述
    &quot;live_status&quot;: 1, // 直播间状态：0-未开播，1-直播中，2-轮播中
    &quot;area_id&quot;: 371, // 分区id
    &quot;parent_area_id&quot;: 9, // 父分区id
    &quot;parent_area_name&quot;: &quot;虚拟主播&quot;, // 父分区名称
    &quot;old_area_id&quot;: 6, // 旧版分区id
    &quot;background&quot;: &quot;&quot;, // 背景图片链接
    &quot;title&quot;: &quot;不给糖就捣蛋&quot;, // 标题
    &quot;user_cover&quot;: &quot;https://i0.hdslb.com/bfs/live/new_room_cover/22426f0ead9804fabd06ad1c4305e2641a4e6d11.jpg&quot;, // 封面
    &quot;keyframe&quot;: &quot;https://i0.hdslb.com/bfs/live-key-frame/keyframe11011431000027668995752wsi.jpg&quot;, // 关键帧
    &quot;is_strict_room&quot;: false, // 待观测
    &quot;live_time&quot;: &quot;2024-11-01 14:25:09&quot;, // 开播时间
    &quot;tags&quot;: &quot;御姐,屑狐狸,狐仙,憨憨,温柔&quot;, // 标签
    &quot;is_anchor&quot;: 0, // 待观测
    &quot;room_silent_type&quot;: &quot;&quot;, // 禁言状态
    &quot;room_silent_level&quot;: 0, // 禁言等级
    &quot;room_silent_second&quot;: 0, // 禁言等级（单位秒）
    &quot;area_name&quot;: &quot;虚拟日常&quot;, // 分区名称
    &quot;pendants&quot;: &quot;&quot;, // 待观测
    &quot;area_pendants&quot;: &quot;&quot;, // 待观测
    &quot;hot_words&quot;: [
      &quot;2333333&quot;,
      &quot;喂，妖妖零吗&quot;,
      &quot;红红火火恍恍惚惚&quot;,
      &quot;FFFFFFFFFF&quot;,
      &quot;Yooooooo&quot;,
      &quot;啪啪啪啪啪&quot;,
      &quot;666666666&quot;,
      &quot;老司机带带我&quot;,
      &quot;你为什么这么熟练啊&quot;,
      &quot;gg&quot;,
      &quot;prprpr&quot;,
      &quot;向大佬低头&quot;,
      &quot;请大家注意弹幕礼仪哦！&quot;,
      &quot;还有这种操作！&quot;,
      &quot;囍&quot;,
      &quot;打call&quot;,
      &quot;你气不气？&quot;,
      &quot;队友呢？&quot;
    ], // 热词
    &quot;hot_words_status&quot;: 0, // 热词状态
    &quot;verify&quot;: &quot;&quot;, // 待观测
    &quot;new_pendants&quot;: {
      // 头像框
      &quot;frame&quot;: {
        // 头像框信息
        &quot;name&quot;: &quot;大乱斗乱斗之王&quot;, // 名称
        &quot;value&quot;: &quot;https://i0.hdslb.com/bfs/live/fc28a2a4123154012e0ce3da1273de5f17e81b24.png&quot;, // 头像框图片URL
        &quot;position&quot;: 0, // 位置
        &quot;desc&quot;: &quot;&quot;, // 描述
        &quot;area&quot;: 0, // 分区
        &quot;area_old&quot;: 0, // 旧分区
        &quot;bg_color&quot;: &quot;&quot;, // 背景色
        &quot;bg_pic&quot;: &quot;&quot;, // 背景图
        &quot;use_old_area&quot;: false // 是否旧分区号
      },
      &quot;badge&quot;: {
        // 大V才会有的信息
        &quot;name&quot;: &quot;v_person&quot;, // 认证类型：v_person=个人认证(黄)，v_company=企业认证(蓝)
        &quot;position&quot;: 3, // 位置，可能是个枚举
        &quot;value&quot;: &quot;&quot;, // 待观测
        &quot;desc&quot;: &quot;bilibili 知名UP主、直播高能主播&quot; // 描述
      },
      &quot;mobile_frame&quot;: {
        // 头像框信息，手机版，可能为null
        &quot;name&quot;: &quot;大乱斗乱斗之王&quot;, // 名称
        &quot;value&quot;: &quot;https://i0.hdslb.com/bfs/live/fc28a2a4123154012e0ce3da1273de5f17e81b24.png&quot;, // 头像框图片URL
        &quot;position&quot;: 0, // 位置
        &quot;desc&quot;: &quot;&quot;, // 描述
        &quot;area&quot;: 0, // 分区
        &quot;area_old&quot;: 0, // 旧分区
        &quot;bg_color&quot;: &quot;&quot;, // 背景色
        &quot;bg_pic&quot;: &quot;&quot;, // 背景图
        &quot;use_old_area&quot;: false // 是否旧分区号
      },
      &quot;mobile_badge&quot;: {
        // 大V才会有的信息，手机版，可能为null
        &quot;name&quot;: &quot;v_person&quot;, // 认证类型：v_person=个人认证(黄)，v_company=企业认证(蓝)
        &quot;position&quot;: 3, // 位置，可能是个枚举
        &quot;value&quot;: &quot;&quot;, // 待观测
        &quot;desc&quot;: &quot;bilibili 知名UP主、直播高能主播&quot; // 描述
      }
    },
    &quot;up_session&quot;: &quot;557568462506308099&quot;, // 待观测
    &quot;pk_status&quot;: 0, // pk状态
    &quot;pk_id&quot;: 0, // pk id
    &quot;battle_id&quot;: 0, // 待观测
    &quot;allow_change_area_time&quot;: 0, // 待观测
    &quot;allow_upload_cover_time&quot;: 0, // 待观测
    &quot;studio_info&quot;: {
      // 待观测
      &quot;status&quot;: 0, // 待观测
      &quot;master_list&quot;: []
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;获取信息流认证秘钥&lt;/h2&gt;
&lt;p&gt;该接口可以获取到对应直播间信息流的链接地址，以及建立链接需要认证的 token 信息&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;注意：&lt;/strong&gt; B 站更新了隐私政策, 连接建立 5 分钟左右, 若该连接认证时传入信息来自未登录用户, 会提示 &lt;strong&gt;为保护用户隐私，未注册登陆用户将无法查看他人昵称&lt;/strong&gt;, 随后所有发送弹幕的用户 id 都为 &lt;strong&gt;0&lt;/strong&gt;, 用户名部分也使用 * 保护，因此调用本接口时需要传递 ** cookie **&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;注意：&lt;/strong&gt; &lt;strong&gt;ws&lt;/strong&gt; 与 &lt;strong&gt;wss&lt;/strong&gt; 连接地址带有路径 &lt;strong&gt;/sub&lt;/strong&gt;, 如 &lt;strong&gt;wss://tx-sh-live-comet-08.chat.bilibili.com:443/sub&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;blockquote&gt;
&lt;p&gt;请求方式：&lt;strong&gt;GET&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;请求地址：https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;| 参数名 | 类型 | 内容          |
| :----- | :--- | :------------ |
| id     | int  | 直播间真实 id |&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;请求响应示例与字段说明&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;curl -G &apos;https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo&apos; \
--data-urlencode &apos;id=30118851&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;code&quot;: 0, // 0：成功，65530：token错误（登录错误），1：错误，60009：分区不存在，其他错误仍需观察
  &quot;message&quot;: &quot;0&quot;, // 错误信息
  &quot;ttl&quot;: 1, // 默认为1
  &quot;data&quot;: {
    // 信息本体
    &quot;group&quot;: &quot;live&quot;, // 不重要，默认live
    &quot;business_id&quot;: 0, // 不重要，默认 0
    &quot;refresh_row_factor&quot;: 0.125, // 不重要，默认0.125
    &quot;refresh_rate&quot;: 100, // 不重要，默认100
    &quot;max_delay&quot;: 5000, // 不重要，默认5000
    &quot;token&quot;: &quot;TrF6FaSlmxVBM4eBYGoaWPuZ-xVL-bhK80waLbGRfpj6JiLkjgaxLcu5whFM6iEBrQFw8wJwdraBJwkctMzMrkyP7kmWkRAmFUa_Z1aiXVDhyMwsiQe81KHMGC82tuyWF9iHNstIX-M0IhU=&quot;, // 认证密钥
    &quot;host_list&quot;: [
      // 信息流服务器节点列表
      {
        &quot;host&quot;: &quot;tx-sh-live-comet-08.chat.bilibili.com&quot;, // 服务器域名
        &quot;port&quot;: 2243, // tcp端口
        &quot;wss_port&quot;: 443, // wss 端口
        &quot;ws_port&quot;: 2244 // ws端口
      },
      {
        &quot;host&quot;: &quot;tx-bj-live-comet-08.chat.bilibili.com&quot;,
        &quot;port&quot;: 2243,
        &quot;wss_port&quot;: 443,
        &quot;ws_port&quot;: 2244
      },
      {
        &quot;host&quot;: &quot;broadcastlv.chat.bilibili.com&quot;,
        &quot;port&quot;: 2243,
        &quot;wss_port&quot;: 443,
        &quot;ws_port&quot;: 2244
      }
    ]
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;信息流接入&lt;/h1&gt;
&lt;p&gt;数据包为 MQ（Message Queue，消息队列）使用 Websocket 或 TCP 连接作为通道，具体格式为 &lt;strong&gt;弹幕协议&lt;/strong&gt; + &lt;strong&gt;正文数据&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;操作流程：&lt;/p&gt;
&lt;p&gt;连接信息流服务器节点 -&gt; 发送认证包 -&gt; 接收认证包回应 -&gt; 接收普通包&amp;#x26;（每 30 秒发送心跳包 -&gt; 接收心跳回应）&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;article/e1ccd148/agreement.png&quot; alt=&quot;协议格式：基于websocket之上的应用层协议，所有字段 大端 对齐&quot;&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Packet Length&lt;/strong&gt;：整个 Packet 的长度，包含 Header&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Header Length&lt;/strong&gt;：Header 的长度，固定为 16&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Version&lt;/strong&gt;：协议版本&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Operation&lt;/strong&gt;：操作码&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Sequence ID&lt;/strong&gt;：保留字段，可以忽略&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Body&lt;/strong&gt;：消息体，客户端解析 Body 之前请先解析 Version 字段&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;Version 说明：
0 - 普通包正文不使用压缩
1 - 心跳及认证包正文不使用压缩
2 - 普通包(zlib 压缩)
3 - 普通包(brotli 压缩)&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;Operation 说明：
2 - 客户端发送的心跳包(30 秒发送一次)
3 - 服务器收到心跳包的回复
5 - 服务器推送的弹幕消息包
7 - 客户端发送的鉴权包(客户端发送的第一个包)
8 - 服务器收到鉴权包后的回复&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;注意：B 站最近加强了风控，建议在建立连接时携带好用户的&lt;strong&gt;cookie&lt;/strong&gt;以避免风控限流&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;认证包构建&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;注意: 认证包需要在握手成功 5 秒内发送, 否则强制断开连接&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;认证包头部信息基于上述协议格式不再赘述，仅说明 &lt;strong&gt;Body&lt;/strong&gt; 如何构建&lt;/p&gt;
&lt;p&gt;| 字段     | 类型   | 说明                                        |
| :------- | :----- | :------------------------------------------ |
| uid      | int    | 用户 uid                                    |
| roomid   | int    | 主播房间 id                                 |
| protover | int    | 协议版本，决定了后续数据包的 &lt;strong&gt;Version&lt;/strong&gt;    |
| buvid    | string | 用户&lt;strong&gt;buvid3&lt;/strong&gt;，可在&lt;strong&gt;cookie&lt;/strong&gt;中获得        |
| platform | string | 平台，传&lt;strong&gt;web&lt;/strong&gt;即可                         |
| type     | int    | 不确定用途，目前 B 站网页版传 2，照着传即可 |
| key      | string | &lt;strong&gt;获取信息流认证秘钥&lt;/strong&gt;接口提供的&lt;strong&gt;token&lt;/strong&gt;   |&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;protover 说明：
2 - 后续正文以 zlib 方式返回
3 - 后续正文以 brotli 方式返回&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;示例：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;00000000: 0000 0152 0010 0001 0000 0007 0000 0001  ...R............
00000001: 7b22 7569 6422 3a34 3332 3530 3531 2c22  {&quot;uid&quot;:4325051,&quot;
00000002: 726f 6f6d 6964 223a 3331 3432 3735 3432  roomid&quot;:31427542
00000003: 2c22 7072 6f74 6f76 6572 223a 332c 2262  ,&quot;protover&quot;:3,&quot;b
00000004: 7576 6964 223a 2232 4445 3846 4141 312d  uvid&quot;:&quot;2DE8FAA1-
00000005: 3642 4643 2d45 3741 412d 3031 3041 2d31  6BFC-E7AA-010A-1
00000006: 3039 4544 3039 3443 4245 3537 3935 3139  09ED094CBE579519
00000007: 696e 666f 6322 2c22 706c 6174 666f 726d  infoc&quot;,&quot;platform
00000008: 223a 2277 6562 222c 2274 7970 6522 3a32  &quot;:&quot;web&quot;,&quot;type&quot;:2
00000009: 2c22 6b65 7922 3a22 375f 6573 4f70 564e  ,&quot;key&quot;:&quot;7_esOpVN
0000000a: 697a 5570 4732 7069 3169 7741 2d79 3651  izUpG2pi1iwA-y6Q
0000000b: 4545 4550 734a 7872 666a 6c4c 5f73 4e50  EEEPsJxrfjlL_sNP
0000000c: 6b77 4f55 385a 7255 4150 334a 7951 746e  kwOU8ZrUAP3JyQtn
0000000d: 4154 6748 474c 645f 514a 616b 794b 4d54  ATgHGLd_QJakyKMT
0000000e: 4b75 717a 3856 5174 6474 5479 5f75 476c  Kuqz8VQtdtTy_uGl
0000000f: 5541 3958 6d75 334f 507a 6944 5170 3952  UA9Xmu3OPziDQp9R
00000010: 5832 6f57 4366 5356 3345 7778 3554 4532  X2oWCfSV3Ewx5TE2
00000011: 6c6a 4552 616e 684f 3757 7230 695f 3641  ljERanhO7Wr0i_6A
00000012: 584f 3862 6d38 634f 5757 5649 4a31 7966  XO8bm8cOWWVIJ1yf
00000013: 5535 4c63 7638 484b 3864 564e 4954 6e74  U5Lcv8HK8dVNITnt
00000014: 7144 4669 7339 5471 586d 544f 344e 413d  qDFis9TqXmTO4NA=
00000015: 227d                                     &quot;}

&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;命令列表&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;SEND_GIFT&lt;/strong&gt;：赠送礼物&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;DANMU_MSG&lt;/strong&gt;：弹幕信息&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;GUARD_BUY&lt;/strong&gt;：开通舰长&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;GUARD_LOTTERY_START&lt;/strong&gt;：千舰推送&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;USER_TOAST_MSG&lt;/strong&gt;：上舰抽奖消息推送&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SUPER_CHAT_MESSAGE&lt;/strong&gt;：醒目留言&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;ENTRY_EFFECT&lt;/strong&gt;：舰长进入直播间（进入直播间特效）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;INTERACT_WORD&lt;/strong&gt;：直播间互动&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PK_BATTLE_PRE_NEW&lt;/strong&gt;：PK 即将开始&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;命令说明&lt;/h2&gt;
&lt;h3&gt;赠送礼物:SEND_GIFT&lt;/h3&gt;
&lt;p&gt;| 字段                                                  | 字段类型 | 字段说明                                                             |
| :---------------------------------------------------- | :------- | :------------------------------------------------------------------- |
| cmd                                                   | string   | 固定命令                                                             |
| data                                                  | array    | 数据信息                                                             |
| data.action                                           | string   | 行为，一般都是投喂                                                   |
| data.bag_gift                                         | array    | 从背包中赠送礼物时会出现                                             |
| data.bag_gift.price_for_show                          | int      | 礼品单价（电池）                                                     |
| data.bag_gift.show_price                              | int      | 赠送数量                                                             |
| data.batch_combo_id                                   | string   | 连击 ID                                                              |
| data.batch_combo_send                                 | array    | 连击信息                                                             |
| data.batch_combo_send.action                          | string   | 行为，一般都是投喂                                                   |
| data.batch_combo_send.batch_combo_id                  | string   | 连击 ID                                                              |
| data.batch_combo_send.batch_combo_num                 | int      | 当前连击数                                                           |
| data.batch_combo_send.blind_gift                      | array    | 相关礼物信息                                                         |
| data.batch_combo_send.blind_gift.blind_gift_config_id | int      | 赠送盲盒时存在数据，疑似是盲盒的配置                                 |
| data.batch_combo_send.blind_gift.from                 | int      | 不确定用途，观测到 &lt;code&gt;blind_gift_config_id&lt;/code&gt; 存在时为 0，其余情况无数据 |
| data.batch_combo_send.blind_gift.gift_action          | string   | 赠送盲盒时为爆出，其余无数据                                         |
| data.batch_combo_send.blind_gift.gift_tip_price       | int      | 赠送盲盒时为爆出的礼物价格，其余无数据                               |
| data.batch_combo_send.blind_gift.original_gift_id     | int      | 赠送盲盒时为盲盒的 gift_id，其余无数据                               |
| data.batch_combo_send.blind_gift.original_gift_name   | string   | 赠送盲盒时为盲盒的名称，其余无数据                                   |
| data.batch_combo_send.blind_gift.original_gift_price  | int      | 赠送盲盒时为盲盒的价格，其余无数据                                   |
| data.batch_combo_send.gift_id                         | int      | 礼物 id，如果是盲盒则为盲盒爆出的礼物 id                             |
| data.batch_combo_send.gift_name                       | string   | 礼物名称，如果是盲盒则为盲盒爆出的礼物名称                           |
| data.batch_combo_send.gift_num                        | int      | 赠送数量                                                             |
| data.batch_combo_send.send_master                     | string   | 不清楚用途                                                           |
| data.batch_combo_send.uid                             | string   | 赠送人 uid                                                           |
| data.batch_combo_send.uname                           | string   | 赠送人名称                                                           |
| data.beatId                                           | int      | 暂不确定用途，观测到多是空或者 0                                     |
| data.biz_source                                       | string   | 暂不确定用途，观测到多是 &lt;code&gt;Live&lt;/code&gt; 跟 &lt;code&gt;live&lt;/code&gt;                            |
| data.blind_gift                                       | array    | 相关礼物信息                                                         |
| data.blind_gift.blind_gift_config_id                  | int      | 赠送盲盒时存在数据，疑似是盲盒的配置                                 |
| data.blind_gift.from                                  | int      | 不确定用途，观测到 &lt;code&gt;blind_gift_config_id&lt;/code&gt; 存在时为 0，其余情况无数据 |
| data.blind_gift.gift_action                           | string   | 赠送盲盒时为爆出，其余无数据                                         |
| data.blind_gift.gift_tip_price                        | int      | 赠送盲盒时为爆出的礼物价格，其余无数据                               |
| data.blind_gift.original_gift_id                      | int      | 赠送盲盒时为盲盒的 gift_id                                           |
| data.blind_gift.original_gift_name                    | string   | 赠送盲盒时为盲盒的名称，其余无数据                                   |
| data.blind_gift.original_gift_price                   | int      | 赠送盲盒时为盲盒的价格，其余无数据                                   |
| data.broadcast_id                                     | int      | 广播 ID，疑似赠送大礼物触发广播时与其绑定                            |
| data.coin_type                                        | string   | 硬币类型，目前观测到非付费礼物为&lt;code&gt;silver&lt;/code&gt;，付费礼物为&lt;code&gt;gold&lt;/code&gt;           |
| data.combo_resources_id                               | int      | 待观测，通常是 0 或 1                                                |
| data.combo_send                                       | array    | 连击信息                                                             |
| data.combo_send.action                                | string   | 行为，一般都是投喂                                                   |
| data.combo_send.combo_id                              | string   | 连击 ID                                                              |
| data.combo_send.combo_num                             | int      | 当前连击数                                                           |
| data.combo_send.gift_id                               | int      | 礼物 id （盲盒为实际爆出礼物）                                       |
| data.combo_send.gift_name                             | string   | 礼物名称（盲盒为实际爆出礼物）                                       |
| data.combo_send.gift_num                              | int      | 数量                                                                 |
| data.combo_send.send_master                           | string   | 未观测到有效数据                                                     |
| data.combo_send.uid                                   | int      | 赠送人 uid                                                           |
| data.combo_send.uname                                 | string   | 赠送人名称                                                           |
| data.combo_stay_time                                  | int      | 连击有效间隔时间                                                     |
| data.combo_total_coin                                 | int      | 连击总费用                                                           |
| data.crit_prob                                        | int      | 待观测，全是 0                                                       |
| data.demarcation                                      | int      | 待观测，数据一般是 1，2，3，应该是分类，看起来数字越大越贵           |
| data.discount_price                                   | int      | 折扣价格，比如不要钱的粉丝团灯牌                                     |
| data.dmscore                                          | int      | 某种评分？待观测                                                     |
| data.draw                                             | int      | 待观测                                                               |
| data.effect                                           | int      | 待观测                                                               |
| data.effect_block                                     | int      | 待观测                                                               |
| data.face                                             | string   | 用户头像 URL                                                         |
| data.face_effect_id                                   | int      | 待观测                                                               |
| data.face_effect_type                                 | int      | 待观测                                                               |
| data.face_effect_v2                                   | array    | 待观测                                                               |
| data.face_effect_v2.id                                | int      | 待观测                                                               |
| data.face_effect_v2.type                              | int      | 待观测                                                               |
| data.float_sc_resource_id                             | int      | 待观测                                                               |
| data.giftId                                           | int      | 礼物 ID                                                              |
| data.giftName                                         | string   | 礼物名称                                                             |
| data.giftType                                         | int      | 礼物类型，枚举，目前发现值 5 疑似免费礼物或背包礼物，其他含义待观测  |
| data.gift_info                                        | array    | 礼物详细信息                                                         |
| data.gift_info.effect_id                              | int      | 某种 ID，目前看好像只有盲盒礼物有，其余为 0                          |
| data.gift_info.gif                                    | string   | 礼物 GIF 图                                                          |
| data.gift_info.has_imaged_gift                        | int      | 某种枚举，effect_id 非 0 时为 1，其余为 0                            |
| data.gift_info.img_basic                              | string   | 礼物 PNG 图                                                          |
| data.gift_info.webp                                   | string   | 礼物 webp 图                                                         |
| data.gift_tag                                         | array    | 礼物标签？                                                           |
| data.gift_tag.0                                       | int      | 标签相关，待观测                                                     |
| data.gold                                             | int      | 待观测                                                               |
| data.group_medal                                      | int      | 套票相关？不确定，待观测                                             |
| data.guard_level                                      | int      | 牌子的大航海类型，0=普通用户，1=总督，2=提督，3=舰长                 |
| data.is_first                                         | bool     | 待观测，应该是标记本场直播是否是第一次赠送                           |
| data.is_join_receiver                                 | bool     | 待观测                                                               |
| data.is_naming                                        | bool     | 待观测                                                               |
| data.is_special_batch                                 | int      | 待观测                                                               |
| data.magnification                                    | int      | 待观测                                                               |
| data.medal_info                                       | array    | 牌子信息                                                             |
| data.medal_info.anchor_roomid                         | int      | 房间号，目前疑似并未使用                                             |
| data.medal_info.anchor_uname                          | string   | 主播名称，目前疑似并未使用                                           |
| data.medal_info.guard_level                           | int      | 牌子的大航海类型，0=普通用户，1=总督，2=提督，3=舰长                 |
| data.medal_info.icon_id                               | int      | 应该是某 ID 相关，待观测，目前全是 0                                 |
| data.medal_info.is_lighted                            | int      | 牌子是否点亮 ，1=是，0=否                                            |
| data.medal_info.medal_color                           | int      | 牌子颜色（十进制数据，需要自己转十六进制颜色代码）                   |
| data.medal_info.medal_color_border                    | int      | 牌子边框颜色（十进制数据，需要自己转十六进制颜色代码）               |
| data.medal_info.medal_color_end                       | int      | 牌子右侧颜色（渐变）（十进制数据，需要自己转十六进制颜色代码）       |
| data.medal_info.medal_color_start                     | int      | 牌子左侧颜色（渐变）（十进制数据，需要自己转十六进制颜色代码）       |
| data.medal_info.medal_level                           | int      | 牌子等级                                                             |
| data.medal_info.medal_name                            | string   | 牌子名称                                                             |
| data.medal_info.special                               | -        | 估计是用来区分特殊牌子的，待观测                                     |
| data.medal_info.target_id                             | int      | 牌子所属主播 uid                                                     |
| data.name_color                                       | string   | 用户名颜色                                                           |
| data.num                                              | int      | 赠送数量                                                             |
| data.original_gift_name                               | string   | 待观测                                                               |
| data.price                                            | int      | 礼物价格                                                             |
| data.rcost                                            | int      | 待观测                                                               |
| data.receive_user_info                                | array    | 收礼人信息（主播）                                                   |
| data.receive_user_info.uid                            | int      | 主播 uid                                                             |
| data.receive_user_info.uname                          | string   | 主播名称                                                             |
| data.receiver_uinfo                                   | array    | 收礼人信息（主播）（估计是新版本）                                   |
| data.receiver_uinfo.base                              | array    | 收礼人（主播）基本信息                                               |
| data.receiver_uinfo.base.face                         | string   | 头像                                                                 |
| data.receiver_uinfo.base.is_mystery                   | bool     | 不确定含义，待观测                                                   |
| data.receiver_uinfo.base.name                         | string   | 名称                                                                 |
| data.receiver_uinfo.base.name_color                   | int      | 待观测，估计是名字颜色十进制数字                                     |
| data.receiver_uinfo.base.name_color_str               | string   | 待观测，估计是名字颜色十六进制代码                                   |
| data.receiver_uinfo.base.official_info                | array    | 待观测，估计是官方相关                                               |
| data.receiver_uinfo.base.official_info.desc           | string   | ？说明？                                                             |
| data.receiver_uinfo.base.official_info.role           | int      | ？角色？                                                             |
| data.receiver_uinfo.base.official_info.title          | string   | ？官方 title？                                                       |
| data.receiver_uinfo.base.official_info.type           | int      | ？类型                                                               |
| data.receiver_uinfo.base.origin_info                  | array    | 待观测，目前看到的都是主播个人信息                                   |
| data.receiver_uinfo.base.origin_info.face             | string   | 头像                                                                 |
| data.receiver_uinfo.base.origin_info.name             | string   | 名称                                                                 |
| data.receiver_uinfo.base.risk_ctrl_info               | array    | 待观测，目前看到的都是主播个人信息                                   |
| data.receiver_uinfo.base.risk_ctrl_info.face          | string   | 头像                                                                 |
| data.receiver_uinfo.base.risk_ctrl_info.name          | string   | 名称                                                                 |
| data.receiver_uinfo.guard                             | -        | 待观测，当前未观测到数据                                             |
| data.receiver_uinfo.guard_leader                      | -        | 待观测，当前未观测到数据                                             |
| data.receiver_uinfo.medal                             | -        | 待观测，当前未观测到数据                                             |
| data.receiver_uinfo.title                             | -        | 待观测，当前未观测到数据                                             |
| data.receiver_uinfo.uhead_frame                       | -        | 待观测，当前未观测到数据                                             |
| data.receiver_uinfo.uid                               | int      | uid                                                                  |
| data.receiver_uinfo.wealth                            | -        | 待观测，当前未观测到数据                                             |
| data.remain                                           | int      | 待观测                                                               |
| data.rnd                                              | string   | 待观测                                                               |
| data.send_master                                      | string   | 待观测                                                               |
| data.sender_uinfo                                     | array    | 送礼人信息                                                           |
| data.sender_uinfo.base                                | array    | 送礼人（用户）基本信息                                               |
| data.sender_uinfo.base.face                           | string   | 头像                                                                 |
| data.sender_uinfo.base.is_mystery                     | bool     | 不确定含义，待观测                                                   |
| data.sender_uinfo.base.name                           | string   | 名称                                                                 |
| data.sender_uinfo.base.name_color                     | int      | 待观测，估计是名字颜色十进制数字                                     |
| data.sender_uinfo.base.name_color_str                 | string   | 待观测，估计是名字颜色十六进制代码                                   |
| data.sender_uinfo.base.official_info                  | array    | 待观测，估计是官方相关                                               |
| data.sender_uinfo.base.official_info.desc             | string   | ？说明？                                                             |
| data.sender_uinfo.base.official_info.role             | int      | ？角色？                                                             |
| data.sender_uinfo.base.official_info.title            | string   | ？官方 title？                                                       |
| data.sender_uinfo.base.official_info.type             | int      | ？类型                                                               |
| data.sender_uinfo.base.origin_info                    | array    | 待观测，目前看到的都是送礼人个人信息                                 |
| data.sender_uinfo.base.origin_info.face               | string   | 头像                                                                 |
| data.sender_uinfo.base.origin_info.name               | string   | 名称                                                                 |
| data.sender_uinfo.base.risk_ctrl_info                 | array    | 待观测，目前看到的都是送礼人个人信息                                 |
| data.sender_uinfo.base.risk_ctrl_info.face            | string   | 头像                                                                 |
| data.sender_uinfo.base.risk_ctrl_info.name            | string   | 名称                                                                 |
| data.sender_uinfo.guard                               | -        | 待观测，目前未观察到数据                                             |
| data.sender_uinfo.guard_leader                        | -        | 待观测，目前未观察到数据                                             |
| data.sender_uinfo.medal                               | array    | 牌子信息                                                             |
| data.sender_uinfo.medal.color                         | int      | 牌子颜色（十进制数据，需要自己转十六进制颜色代码）                   |
| data.sender_uinfo.medal.color_border                  | int      | 牌子边框颜色（十进制数据，需要自己转十六进制颜色代码）               |
| data.sender_uinfo.medal.color_end                     | int      | 牌子右侧颜色（渐变）（十进制数据，需要自己转十六进制颜色代码）       |
| data.sender_uinfo.medal.color_start                   | int      | 牌子左侧颜色（渐变）（十进制数据，需要自己转十六进制颜色代码）       |
| data.sender_uinfo.medal.guard_icon                    | string   | 大航海图标                                                           |
| data.sender_uinfo.medal.guard_level                   | int      | 大航海类型，0=普通用户，1=总督，2=提督，3=舰长                       |
| data.sender_uinfo.medal.honor_icon                    | -        | 待观测，目前无数据                                                   |
| data.sender_uinfo.medal.id                            | int      | 待观测，目前都是 0                                                   |
| data.sender_uinfo.medal.is_light                      | int      | 牌子是否点亮 ，1=是，0=否                                            |
| data.sender_uinfo.medal.level                         | int      | 牌子等级                                                             |
| data.sender_uinfo.medal.name                          | string   | 牌子名称                                                             |
| data.sender_uinfo.medal.ruid                          | int      | 牌子所属主播 uid                                                     |
| data.sender_uinfo.medal.score                         | int      | 某种评分？待观测                                                     |
| data.sender_uinfo.medal.typ                           | int      | 待观测，目前都是 0                                                   |
| data.sender_uinfo.medal.user_receive_count            | int      | 待观测，目前都是 0                                                   |
| data.sender_uinfo.medal.v2_medal_color_border         | string   | v2 版本牌子边框颜色                                                  |
| data.sender_uinfo.medal.v2_medal_color_end            | string   | v2 版本牌子右侧颜色（渐变）                                          |
| data.sender_uinfo.medal.v2_medal_color_level          | int      | v2 版本牌子等级颜色                                                  |
| data.sender_uinfo.medal.v2_medal_color_start          | string   | v2 版本牌子左侧颜色（渐变）                                          |
| data.sender_uinfo.medal.v2_medal_color_text           | string   | v2 版本牌子内容颜色                                                  |
| data.sender_uinfo.title                               | -        | 待观测，目前无数据                                                   |
| data.sender_uinfo.uhead_frame                         | -        | 待观测，目前无数据                                                   |
| data.sender_uinfo.uid                                 | int      | uid                                                                  |
| data.sender_uinfo.wealth                              | int      | 待观测，目前都是 0                                                   |
| data.silver                                           | int      | 待观测，目前都是 0                                                   |
| data.super                                            | int      | 待观测，目前都是 0                                                   |
| data.super_batch_gift_num                             | int      | 待观测，与赠送数量相关                                               |
| data.super_gift_num                                   | int      | 待观测，与赠送数量相关                                               |
| data.svga_block                                       | int      | 待观测，目前都是 0                                                   |
| data.switch                                           | bool     | 待观测，目前都是 TRUE                                                |
| data.tag_image                                        | -        | 待观测，目前无数据                                                   |
| data.tid                                              | string   | 待观测                                                               |
| data.timestamp                                        | int      | 礼物赠送时间（秒级时间戳）                                           |
| data.top_list                                         | -        | 待观测，目前无数据                                                   |
| data.total_coin                                       | int      | 礼物总价，连击时累加                                                 |
| data.uid                                              | int      | 赠送人 uid                                                           |
| data.uname                                            | string   | 赠送人名称                                                           |
| data.wealth_level                                     | int      | 荣耀等级                                                             |
| msg_id                                                | string   | 待观测，感觉跟消息绑定了                                             |
| p_is_ack                                              | bool     | 无数据或者 TRUE，仍需观测                                            |
| p_msg_type                                            | int      | 无数据或者 1，仍需观测                                               |
| send_time                                             | int      | 毫秒级时间戳，有时会没数据，待观测                                   |&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;cmd&quot;: &quot;SEND_GIFT&quot;, // 固定命令
  &quot;data&quot;: {
    // 数据信息
    &quot;action&quot;: &quot;投喂&quot;, // 行为，一般都是投喂
    &quot;bag_gift&quot;: {
      // 从背包中赠送礼物时会出现
      &quot;price_for_show&quot;: 500, // 礼品单价(电池*100)
      &quot;show_price&quot;: 1 // 赠送数量
    },
    &quot;batch_combo_id&quot;: &quot;30d6eb24-88c5-49bf-9023-baa91ec084a7&quot;, // 连击ID
    &quot;batch_combo_send&quot;: {
      // 连击信息
      &quot;action&quot;: &quot;投喂&quot;, // 行为，一般都是投喂
      &quot;batch_combo_id&quot;: &quot;30d6eb24-88c5-49bf-9023-baa91ec084a7&quot;, // 连击ID
      &quot;batch_combo_num&quot;: 1, // 当前连击数
      &quot;blind_gift&quot;: {
        // 相关礼物信息
        &quot;blind_gift_config_id&quot;: 51, // 赠送盲盒时存在数据，疑似是盲盒的配置
        &quot;from&quot;: 0, // 不确定用途，观测到 blind_gift_config_id 存在时为0，其余情况无数据
        &quot;gift_action&quot;: &quot;爆出&quot;, // 赠送盲盒时为爆出，其余无数据
        &quot;gift_tip_price&quot;: 16000, // 赠送盲盒时为爆出的礼物价格，其余无数据
        &quot;original_gift_id&quot;: 32251, // 赠送盲盒时为盲盒的 gift_id，其余无数据
        &quot;original_gift_name&quot;: &quot;心动盲盒&quot;, // 赠送盲盒时为盲盒的名称，其余无数据
        &quot;original_gift_price&quot;: 15000 // 赠送盲盒时为盲盒的价格，其余无数据
      },
      &quot;gift_id&quot;: 32128, // 礼物id，如果是盲盒则为盲盒爆出的礼物id
      &quot;gift_name&quot;: &quot;爱心抱枕&quot;, // 礼物名称，如果是盲盒则为盲盒爆出的礼物名称
      &quot;gift_num&quot;: 1, // 赠送数量
      &quot;send_master&quot;: null, // 不清楚用途
      &quot;uid&quot;: 3494362281412802, // 赠送人uid
      &quot;uname&quot;: &quot;舒葵交错&quot; // 赠送人名称
    },
    &quot;beatId&quot;: &quot;&quot;, // 暂不确定用途，观测到多是空或者0
    &quot;biz_source&quot;: &quot;live&quot;, // 暂不确定用途，观测到多是 Live 跟 live
    &quot;blind_gift&quot;: {
      // 相关礼物信息
      &quot;blind_gift_config_id&quot;: 51, // 赠送盲盒时存在数据，疑似是盲盒的配置
      &quot;from&quot;: 0, // 不确定用途，观测到 blind_gift_config_id 存在时为0，其余情况无数据
      &quot;gift_action&quot;: &quot;爆出&quot;, // 赠送盲盒时为爆出，其余无数据
      &quot;gift_tip_price&quot;: 16000, // 赠送盲盒时为爆出的礼物价格，其余无数据
      &quot;original_gift_id&quot;: 32251, // 赠送盲盒时为盲盒的 gift_id
      &quot;original_gift_name&quot;: &quot;心动盲盒&quot;, // 赠送盲盒时为盲盒的名称，其余无数据
      &quot;original_gift_price&quot;: 15000 // 赠送盲盒时为盲盒的价格，其余无数据
    },
    &quot;broadcast_id&quot;: 0, // 广播ID，疑似赠送大礼物触发广播时与其绑定
    &quot;coin_type&quot;: &quot;gold&quot;, // 硬币类型，目前观测到非付费礼物为silver，付费礼物为gold
    &quot;combo_resources_id&quot;: 1, // 待观测，通常是 0 或 1
    &quot;combo_send&quot;: {
      // 连击信息
      &quot;action&quot;: &quot;投喂&quot;, // 行为，一般都是投喂
      &quot;combo_id&quot;: &quot;c210a464-9620-425e-b9e3-73db191ed1c5&quot;, // 连击ID
      &quot;combo_num&quot;: 1, // 当前连击数
      &quot;gift_id&quot;: 32128, // 礼物id （盲盒为实际爆出礼物）
      &quot;gift_name&quot;: &quot;爱心抱枕&quot;, // 礼物名称（盲盒为实际爆出礼物）
      &quot;gift_num&quot;: 1, // 数量
      &quot;send_master&quot;: null, // 未观测到有效数据
      &quot;uid&quot;: 3494362281412802, // 赠送人uid
      &quot;uname&quot;: &quot;舒葵交错&quot; // 赠送人名称
    },
    &quot;combo_stay_time&quot;: 5, // 连击有效间隔时间
    &quot;combo_total_coin&quot;: 16000, // 连击总费用
    &quot;crit_prob&quot;: 0, // 待观测，全是0
    &quot;demarcation&quot;: 2, // 待观测，数据一般是1，2，3，应该是分类，看起来数字越大越贵
    &quot;discount_price&quot;: 16000, // 折扣价格，比如不要钱的粉丝团灯牌
    &quot;dmscore&quot;: 952, // 某种评分？待观测
    &quot;draw&quot;: 0, // 待观测
    &quot;effect&quot;: 0, // 待观测
    &quot;effect_block&quot;: 0, // 待观测
    &quot;face&quot;: &quot;https://i2.hdslb.com/bfs/face/5d620fea3b4586311884155153a7091b79d0777d.jpg&quot;, // 用户头像URL
    &quot;face_effect_id&quot;: 0, // 待观测
    &quot;face_effect_type&quot;: 0, // 待观测
    &quot;face_effect_v2&quot;: {
      // 待观测
      &quot;id&quot;: 0, // 待观测
      &quot;type&quot;: 0 // 待观测
    },
    &quot;float_sc_resource_id&quot;: 0, // 待观测
    &quot;giftId&quot;: 32128, // 礼物ID
    &quot;giftName&quot;: &quot;爱心抱枕&quot;, // 礼物名称
    &quot;giftType&quot;: 0, // 礼物类型，枚举，目前发现值 5 疑似免费礼物或背包礼物，其他含义待观测
    &quot;gift_info&quot;: {
      // 礼物详细信息
      &quot;effect_id&quot;: 0, // 某种ID，目前看好像只有盲盒礼物有，其余为0
      &quot;gif&quot;: &quot;https://i0.hdslb.com/bfs/live/ae80d80ea758ff08fb4e2c4226ab7b5011b728a6.gif&quot;, // 礼物GIF图
      &quot;has_imaged_gift&quot;: 0, // 某种枚举，effect_id非0时为1，其余为0
      &quot;img_basic&quot;: &quot;https://s1.hdslb.com/bfs/live/824714c830966d7bec381e35ef808b1f478e21ee.png&quot;, // 礼物PNG图
      &quot;webp&quot;: &quot;https://i0.hdslb.com/bfs/live/32c8ee42566501822d8ecc68b33cd2c64937266a.webp&quot; // 礼物webp图
    },
    &quot;gift_tag&quot;: [
      // 礼物标签？
      1101 // 标签相关，待观测
    ],
    &quot;gold&quot;: 0, // 待观测
    &quot;group_medal&quot;: null, // 套票相关？不确定，待观测
    &quot;guard_level&quot;: 3, // 牌子的大航海类型，0=普通用户，1=总督，2=提督，3=舰长
    &quot;is_first&quot;: true, // 待观测，应该是标记本场直播是否是第一次赠送
    &quot;is_join_receiver&quot;: false, // 待观测
    &quot;is_naming&quot;: false, // 待观测
    &quot;is_special_batch&quot;: 0, // 待观测
    &quot;magnification&quot;: 1, // 待观测
    &quot;medal_info&quot;: {
      // 牌子信息
      &quot;anchor_roomid&quot;: 0, // 房间号，目前疑似并未使用
      &quot;anchor_uname&quot;: &quot;&quot;, // 主播名称，目前疑似并未使用
      &quot;guard_level&quot;: 3, // 牌子的大航海类型，0=普通用户，1=总督，2=提督，3=舰长
      &quot;icon_id&quot;: 0, // 应该是某ID相关，待观测，目前全是0
      &quot;is_lighted&quot;: 1, // 牌子是否点亮 ，1=是，0=否
      &quot;medal_color&quot;: 398668, // 牌子颜色（十进制数据，需要自己转十六进制颜色代码）
      &quot;medal_color_border&quot;: 6809855, // 牌子边框颜色（十进制数据，需要自己转十六进制颜色代码）
      &quot;medal_color_end&quot;: 6850801, // 牌子右侧颜色（渐变）（十进制数据，需要自己转十六进制颜色代码）
      &quot;medal_color_start&quot;: 398668, // 牌子左侧颜色（渐变）（十进制数据，需要自己转十六进制颜色代码）
      &quot;medal_level&quot;: 25, // 牌子等级
      &quot;medal_name&quot;: &quot;娴丢人&quot;, // 牌子名称
      &quot;special&quot;: &quot;&quot;, // 估计是用来区分特殊牌子的，待观测
      &quot;target_id&quot;: 3493262186776769 // 牌子所属主播uid
    },
    &quot;name_color&quot;: &quot;#00D1F1&quot;, // 用户名颜色
    &quot;num&quot;: 1, // 赠送数量
    &quot;original_gift_name&quot;: &quot;&quot;, // 待观测
    &quot;price&quot;: 16000, // 礼物价格
    &quot;rcost&quot;: 11936982, // 待观测
    &quot;receive_user_info&quot;: {
      // 收礼人信息（主播）
      &quot;uid&quot;: 3494365156608185, // 主播uid
      &quot;uname&quot;: &quot;温以泠&quot; // 主播名称
    },
    &quot;receiver_uinfo&quot;: {
      // 收礼人信息（主播）（估计是新版本）
      &quot;base&quot;: {
        // 收礼人（主播）基本信息
        &quot;face&quot;: &quot;https://i2.hdslb.com/bfs/face/00e03b4b528b6a475d2987f44002c61c4a75d77c.jpg&quot;, // 头像
        &quot;is_mystery&quot;: false, // 不确定含义，待观测
        &quot;name&quot;: &quot;温以泠&quot;, // 名称
        &quot;name_color&quot;: 0, // 待观测，估计是名字颜色十进制数字
        &quot;name_color_str&quot;: &quot;&quot;, // 待观测，估计是名字颜色十六进制代码
        &quot;official_info&quot;: {
          // 待观测，估计是官方相关
          &quot;desc&quot;: &quot;&quot;, // ？说明？
          &quot;role&quot;: 0, // ？角色？
          &quot;title&quot;: &quot;&quot;, // ？官方title？
          &quot;type&quot;: -1 // ？类型？
        },
        &quot;origin_info&quot;: {
          // 待观测，目前看到的都是主播个人信息
          &quot;face&quot;: &quot;https://i2.hdslb.com/bfs/face/00e03b4b528b6a475d2987f44002c61c4a75d77c.jpg&quot;, // 头像
          &quot;name&quot;: &quot;温以泠&quot; // 名称
        },
        &quot;risk_ctrl_info&quot;: {
          // 待观测，目前看到的都是主播个人信息
          &quot;face&quot;: &quot;https://i2.hdslb.com/bfs/face/00e03b4b528b6a475d2987f44002c61c4a75d77c.jpg&quot;, // 头像
          &quot;name&quot;: &quot;温以泠&quot; // 名称
        }
      },
      &quot;guard&quot;: null, // 待观测，当前未观测到数据
      &quot;guard_leader&quot;: null, // 待观测，当前未观测到数据
      &quot;medal&quot;: null, // 待观测，当前未观测到数据
      &quot;title&quot;: null, // 待观测，当前未观测到数据
      &quot;uhead_frame&quot;: null, // 待观测，当前未观测到数据
      &quot;uid&quot;: 3494365156608185, // 主播uid
      &quot;wealth&quot;: null // 待观测，当前未观测到数据
    },
    &quot;remain&quot;: 0, // 待观测
    &quot;rnd&quot;: &quot;4568350171557109760&quot;, // 待观测
    &quot;send_master&quot;: null, // 待观测
    &quot;sender_uinfo&quot;: {
      // 送礼人信息
      &quot;base&quot;: {
        // 送礼人（用户）基本信息
        &quot;face&quot;: &quot;https://i2.hdslb.com/bfs/face/5d620fea3b4586311884155153a7091b79d0777d.jpg&quot;, // 头像
        &quot;is_mystery&quot;: false, // 不确定含义，待观测
        &quot;name&quot;: &quot;舒葵交错&quot;, // 名称
        &quot;name_color&quot;: 0, // 待观测，估计是名字颜色十进制数字
        &quot;name_color_str&quot;: &quot;&quot;, // 待观测，估计是名字颜色十六进制代码
        &quot;official_info&quot;: {
          // 待观测，估计是官方相关
          &quot;desc&quot;: &quot;&quot;, // ？说明？
          &quot;role&quot;: 0, // ？角色？
          &quot;title&quot;: &quot;&quot;, // ？官方title？
          &quot;type&quot;: -1 // ？类型？
        },
        &quot;origin_info&quot;: {
          // 待观测，目前看到的都是送礼人个人信息
          &quot;face&quot;: &quot;https://i2.hdslb.com/bfs/face/5d620fea3b4586311884155153a7091b79d0777d.jpg&quot;, // 头像
          &quot;name&quot;: &quot;舒葵交错&quot; // 名称
        },
        &quot;risk_ctrl_info&quot;: {
          // 待观测，目前看到的都是送礼人个人信息
          &quot;face&quot;: &quot;https://i2.hdslb.com/bfs/face/5d620fea3b4586311884155153a7091b79d0777d.jpg&quot;, // 头像
          &quot;name&quot;: &quot;舒葵交错&quot; // 名称
        }
      },
      &quot;guard&quot;: null, // 待观测，目前未观察到数据
      &quot;guard_leader&quot;: null, // 待观测，目前未观察到数据
      &quot;medal&quot;: {
        // 牌子信息
        &quot;color&quot;: 398668, // 牌子颜色（十进制数据，需要自己转十六进制颜色代码）
        &quot;color_border&quot;: 6809855, // 牌子边框颜色（十进制数据，需要自己转十六进制颜色代码）
        &quot;color_end&quot;: 6850801, // 牌子右侧颜色（渐变）（十进制数据，需要自己转十六进制颜色代码）
        &quot;color_start&quot;: 398668, // 牌子左侧颜色（渐变）（十进制数据，需要自己转十六进制颜色代码）
        &quot;guard_icon&quot;: &quot;https://i0.hdslb.com/bfs/live/143f5ec3003b4080d1b5f817a9efdca46d631945.png&quot;, // 大航海图标
        &quot;guard_level&quot;: 3, // 大航海类型，0=普通用户，1=总督，2=提督，3=舰长
        &quot;honor_icon&quot;: &quot;&quot;, // 待观测，目前无数据
        &quot;id&quot;: 0, // 待观测，目前都是0
        &quot;is_light&quot;: 1, // 牌子是否点亮 ，1=是，0=否
        &quot;level&quot;: 27, // 牌子等级
        &quot;name&quot;: &quot;泠妻&quot;, // 牌子名称
        &quot;ruid&quot;: 3494365156608185, // 牌子所属主播uid
        &quot;score&quot;: 50093206, // 某种评分？待观测
        &quot;typ&quot;: 0, // 待观测，目前都是0
        &quot;user_receive_count&quot;: 0, // 待观测，目前都是0
        &quot;v2_medal_color_border&quot;: &quot;#58A1F8FF&quot;, // v2版本牌子边框颜色
        &quot;v2_medal_color_end&quot;: &quot;#4775EFCC&quot;, // v2版本牌子右侧颜色（渐变）
        &quot;v2_medal_color_level&quot;: &quot;#000B7099&quot;, // v2版本牌子等级颜色
        &quot;v2_medal_color_start&quot;: &quot;#4775EFCC&quot;, // v2版本牌子左侧颜色（渐变）
        &quot;v2_medal_color_text&quot;: &quot;#FFFFFFFF&quot; // v2版本牌子内容颜色
      },
      &quot;title&quot;: null, // 待观测，目前无数据
      &quot;uhead_frame&quot;: null, // 待观测，目前无数据
      &quot;uid&quot;: 3494362281412802, // 赠送人uid
      &quot;wealth&quot;: null // 待观测，目前都是0
    },
    &quot;silver&quot;: 0, // 待观测，目前都是0
    &quot;super&quot;: 0, // 待观测，目前都是0
    &quot;super_batch_gift_num&quot;: 1, // 待观测，与赠送数量相关
    &quot;super_gift_num&quot;: 1, // 待观测，与赠送数量相关
    &quot;svga_block&quot;: 0, // 待观测，目前都是0
    &quot;switch&quot;: true, // 待观测，目前都是true
    &quot;tag_image&quot;: &quot;&quot;, // 待观测，目前无数据
    &quot;tid&quot;: &quot;4568350171557109760&quot;, // 待观测
    &quot;timestamp&quot;: 1730123987, // 礼物赠送时间（秒级时间戳）
    &quot;top_list&quot;: null, // 待观测，目前无数据
    &quot;total_coin&quot;: 15000, // 礼物总价，连击时累加
    &quot;uid&quot;: 3494362281412802, // 赠送人uid
    &quot;uname&quot;: &quot;舒葵交错&quot;, // 赠送人名称
    &quot;wealth_level&quot;: 35 // 荣耀等级
  },
  &quot;msg_id&quot;: &quot;21794708686651904:1000:1000&quot;, // 待观测，感觉跟消息绑定了
  &quot;p_is_ack&quot;: true, // 无数据或者TRUE，仍需观测
  &quot;p_msg_type&quot;: 1, // 无数据或者1，仍需观测
  &quot;send_time&quot;: 1730123987816 // 毫秒级时间戳，有时会没数据，待观测
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;弹幕信息:DANMU_MSG&lt;/h3&gt;
&lt;p&gt;| 字段                                        | 字段类型        | 字段说明                                                          |
| :------------------------------------------ | :-------------- | :---------------------------------------------------------------- |
| cmd                                         | string          | 命令                                                              |
| dm_v2                                       | -               | 待观察                                                            |
| info                                        | array           | 数据信息                                                          |
| info.0                                      | array           | 弹幕信息                                                          |
| info.0.0                                    | int             | 待观测，目前全是 0                                                |
| info.0.1                                    | int             | 弹幕的 mode 字段                                                  |
| info.0.2                                    | int             | 弹幕的 fontsize 字段                                              |
| info.0.3                                    | int             | 弹幕颜色（十进制数据，需要自己转十六进制颜色代码）                |
| info.0.4                                    | int             | 发送时的 UNIX 毫秒时间戳                                          |
| info.0.5                                    | int             | 待观测                                                            |
| info.0.6                                    | int             | 待观测，目前全是 0                                                |
| info.0.7                                    | string          | 一个十六进制数，待观测，可能是颜色                                |
| info.0.8                                    | int             | 待观测，目前全是 0                                                |
| info.0.9                                    | int             | 待观测，目前全是 0                                                |
| info.0.10                                   | int             | 待观测，疑似某种类型                                              |
| info.0.11                                   | string          | 待观测，通常为空字符串，&lt;code&gt;info.0.10 == 5&lt;/code&gt; 返回一组十六进制颜色代码 |
| info.0.12                                   | int             | 消息中是否携带表情，1=是，0=否                                    |
| info.0.13                                   | string 或 array | 表情信息，info.0.12 == 1 时为 array，其余时候为字符串             |
| info.0.13.bulge_display                     | int             | 某种枚举，待观测                                                  |
| info.0.13.emoticon_unique                   | string          | 表情符号，类似表情 ID 性质                                        |
| info.0.13.height                            | int             | 表情高度                                                          |
| info.0.13.in_player_area                    | int             | 某种枚举，待观测                                                  |
| info.0.13.is_dynamic                        | int             | 是否是动态表情，0=否，1=是                                        |
| info.0.13.url                               | string          | 表情 URL 图片地址                                                 |
| info.0.13.width                             | int             | 表情宽度                                                          |
| info.0.14                                   | string          | 字符串表示的 JSON Object,目前未观测到数据                         |
| info.0.15                                   | array           | 弹幕补充信息，大部分数据都可以在里面获取                          |
| info.0.15.extra                             | string          | 补充信息，json 字符串，需要自行转换为对象                         |
| info.0.15.extra.send_from_me                | bool            | 是否是自己发送                                                    |
| info.0.15.extra.mode                        | int             | 弹幕模式 (等同于 info[0][1])                                      |
| info.0.15.extra.color                       | int             | 弹幕颜色 (等同于 info[0][3])                                      |
| info.0.15.extra.dm_type                     | int             | 某种枚举，待观测                                                  |
| info.0.15.extra.font_size                   | int             | 字体大小（等同于 info[0][2]）                                     |
| info.0.15.extra.player_mode                 | int             | 某种枚举，待观测                                                  |
| info.0.15.extra.show_player_type            | int             | 某种枚举，待观测                                                  |
| info.0.15.extra.content                     | string          | 弹幕文本信息（等同于 info[1]）                                    |
| info.0.15.extra.user_hash                   | string          | 待观测                                                            |
| info.0.15.extra.emoticon_unique             | string          | 待观测                                                            |
| info.0.15.extra.bulge_display               | int             | 待观测                                                            |
| info.0.15.extra.recommend_score             | int             | 待观测                                                            |
| info.0.15.extra.main_state_dm_color         | string          | 待观测                                                            |
| info.0.15.extra.objective_state_dm_color    | string          | 待观测                                                            |
| info.0.15.extra.direction                   | int             | 待观测                                                            |
| info.0.15.extra.pk_direction                | int             | 待观测                                                            |
| info.0.15.extra.quartet_direction           | int             | 待观测                                                            |
| info.0.15.extra.anniversary_crowd           | int             | 待观测                                                            |
| info.0.15.extra.yeah_space_type             | string          | 待观测                                                            |
| info.0.15.extra.yeah_space_url              | string          | 待观测                                                            |
| info.0.15.extra.jump_to_url                 | string          | 待观测                                                            |
| info.0.15.extra.space_type                  | string          | 待观测                                                            |
| info.0.15.extra.space_url                   | string          | 待观测                                                            |
| info.0.15.extra.animation                   | array           | 待观测，目前未观测到数据                                          |
| info.0.15.extra.emots                       | array           | 文本中使用过的表情信息，对象类型，key 为表情，例如：[藏狐]        |
| info.0.15.extra.emots.[藏狐].count          | int             | 文本中出现的数量                                                  |
| info.0.15.extra.emots.[藏狐].descript       | string          | 描述                                                              |
| info.0.15.extra.emots.[藏狐].emoji          | string          | 描述                                                              |
| info.0.15.extra.emots.[藏狐].emoticon_id    | int             | 待观测                                                            |
| info.0.15.extra.emots.[藏狐].emoji          | string          | 待观测                                                            |
| info.0.15.extra.emots.[藏狐].height         | int             | 图片高度                                                          |
| info.0.15.extra.emots.[藏狐].url            | string          | 图片 url                                                          |
| info.0.15.extra.emots.[藏狐].width          | int             | 图片宽度                                                          |
| info.0.15.extra.is_audited                  | bool            | 待观测                                                            |
| info.0.15.extra.id_str                      | string          | 待观测                                                            |
| info.0.15.extra.icon                        | -               | 待观测                                                            |
| info.0.15.extra.show_reply                  | bool            | 是否允许回复                                                      |
| info.0.15.extra.reply_mid                   | int             | 回复消息发送人 uid                                                |
| info.0.15.extra.reply_uname                 | string          | 回复消息发送人名称                                                |
| info.0.15.extra.reply_uname_color           | string          | 回复消息发送人名字颜色                                            |
| info.0.15.extra.reply_is_mystery            | bool            | 待观测                                                            |
| info.0.15.extra.reply_type_enum             | int             | 待观测，某种枚举                                                  |
| info.0.15.extra.hit_combo                   | int             | 待观测，疑似是一些重内容连续发送的连击数                          |
| info.0.15.extra.esports_jump_url            | string          | 待观测                                                            |
| info.0.15.mode                              | int             | 某种枚举，待观测                                                  |
| info.0.15.show_player_type                  | int             | 某种枚举，待观测                                                  |
| info.0.15.user                              | array           | 弹幕发送人信息                                                    |
| info.0.15.user.base                         | array           | 发送人基本信息                                                    |
| info.0.15.user.base.face                    | string          | 头像                                                              |
| info.0.15.user.base.is_mystery              | bool            | 不确定含义，待观测                                                |
| info.0.15.user.base.name                    | string          | 名称                                                              |
| info.0.15.user.base.name_color              | int             | 待观测，估计是名字颜色十进制数字                                  |
| info.0.15.user.base.name_color_str          | string          | 待观测，估计是名字颜色十六进制代码                                |
| info.0.15.user.base.official_info           | array           | 待观测，估计是官方相关                                            |
| info.0.15.user.base.official_info.desc      | string          | ？说明？                                                          |
| info.0.15.user.base.official_info.role      | int             | ？角色？                                                          |
| info.0.15.user.base.official_info.title     | string          | ？官方 title？                                                    |
| info.0.15.user.base.official_info.type      | int             | ？类型                                                            |
| info.0.15.user.base.origin_info             | array           | 待观测，目前看到的都是发送人个人信息                              |
| info.0.15.user.base.origin_info.face        | string          | 头像                                                              |
| info.0.15.user.base.origin_info.name        | string          | 名称                                                              |
| info.0.15.user.base.risk_ctrl_info          | array           | 待观测，目前看到的都是发送人个人信息                              |
| info.0.15.user.base.risk_ctrl_info.face     | string          | 头像                                                              |
| info.0.15.user.base.risk_ctrl_info.name     | string          | 名称                                                              |
| info.0.15.user.guard                        | -               | 待观测，怀疑可能跟超管有关                                        |
| info.0.15.user.guard_leader                 | array           | 待观测，怀疑可能跟超管有关                                        |
| info.0.15.user.guard_leader.is_guard_leader | bool            | 待观测，怀疑可能跟超管有关                                        |
| info.0.15.user.medal                        | array           | 牌子信息                                                          |
| info.0.15.user.medal.color                  | int             | 牌子颜色（十进制数据，需要自己转十六进制颜色代码）                |
| info.0.15.user.medal.color_border           | int             | 牌子边框颜色（十进制数据，需要自己转十六进制颜色代码）            |
| info.0.15.user.medal.color_end              | int             | 牌子右侧颜色（渐变）（十进制数据，需要自己转十六进制颜色代码）    |
| info.0.15.user.medal.color_start            | int             | 牌子左侧颜色（渐变）（十进制数据，需要自己转十六进制颜色代码）    |
| info.0.15.user.medal.guard_icon             | string          | 大航海图标                                                        |
| info.0.15.user.medal.guard_level            | int             | 大航海类型，0=普通用户，1=总督，2=提督，3=舰长                    |
| info.0.15.user.medal.honor_icon             | -               | 待观测，目前无数据                                                |
| info.0.15.user.medal.id                     | int             | 待观测，目前都是 0                                                |
| info.0.15.user.medal.is_light               | int             | 牌子是否点亮 ，1=是，0=否                                         |
| info.0.15.user.medal.level                  | int             | 牌子等级                                                          |
| info.0.15.user.medal.name                   | string          | 牌子名称                                                          |
| info.0.15.user.medal.ruid                   | int             | 牌子所属主播 uid                                                  |
| info.0.15.user.medal.score                  | int             | 某种评分？待观测                                                  |
| info.0.15.user.medal.typ                    | int             | 待观测，目前都是 0                                                |
| info.0.15.user.medal.user_receive_count     | int             | 待观测，目前都是 0                                                |
| info.0.15.user.medal.v2_medal_color_border  | string          | v2 版本牌子边框颜色                                               |
| info.0.15.user.medal.v2_medal_color_end     | string          | v2 版本牌子右侧颜色（渐变）                                       |
| info.0.15.user.medal.v2_medal_color_level   | int             | v2 版本牌子等级颜色                                               |
| info.0.15.user.medal.v2_medal_color_start   | string          | v2 版本牌子左侧颜色（渐变）                                       |
| info.0.15.user.medal.v2_medal_color_text    | string          | v2 版本牌子内容颜色                                               |
| info.0.15.user.title                        | array           | 待观测                                                            |
| info.0.15.user.title.title_css_id           | string          | 待观测                                                            |
| info.0.15.user.title.old_title_css_id       | string          | 待观测                                                            |
| info.0.15.user.uhead_frame                  | -               | 待观测，目前无数据                                                |
| info.0.15.user.uid                          | int             | uid                                                               |
| info.0.15.user.wealth                       | int             | 待观测，目前都是 0                                                |
| info.0.16                                   | array           | 疑似活动信息，待观测                                              |
| info.0.16.activity_identity                 | string          | 待观测                                                            |
| info.0.16.activity_source                   | int             | 待观测                                                            |
| info.0.16.not_show                          | int             | 待观测                                                            |
| info.0.17                                   | int             | 待观测                                                            |
| info.1                                      | string          | 弹幕信息                                                          |
| info.2                                      | array           | 用户信息                                                          |
| info.2.0                                    | int             | 用户 uid                                                          |
| info.2.1                                    | string          | 用户名称                                                          |
| info.2.2                                    | int             | 待观测                                                            |
| info.2.3                                    | int             | 待观测                                                            |
| info.2.4                                    | int             | 待观测                                                            |
| info.2.5                                    | int             | 待观测                                                            |
| info.2.6                                    | int             | 待观测                                                            |
| info.2.7                                    | string          | 待观测，某种颜色信息，有时为空                                    |
| info.3                                      | array           | 牌子信息                                                          |
| info.3.0                                    | int             | 牌子等级                                                          |
| info.3.1                                    | string          | 牌子名称                                                          |
| info.3.2                                    | string          | 牌子所属主播名称                                                  |
| info.3.3                                    | int             | 牌子所属直播间房间号                                              |
| info.3.4                                    | int             | 牌子颜色（十进制数据，需要自己转十六进制颜色代码）                |
| info.3.5                                    | string          | 待观测，一直是空                                                  |
| info.3.6                                    | int             | 待观测，一直是 0                                                  |
| info.3.7                                    | int             | 牌子边框颜色（十进制数据，需要自己转十六进制颜色代码）            |
| info.3.8                                    | int             | 牌子左侧颜色（渐变）（十进制数据，需要自己转十六进制颜色代码）    |
| info.3.9                                    | int             | 牌子右侧颜色（渐变）（十进制数据，需要自己转十六进制颜色代码）    |
| info.3.10                                   | int             | 大航海类型，0=普通用户，1=总督，2=提督，3=舰长                    |
| info.3.11                                   | int             | 牌子是否点亮 ，1=是，0=否                                         |
| info.3.12                                   | int             | 牌子所属主播 uid                                                  |
| info.4                                      | array           | 疑似什么等级信息                                                  |
| info.4.0                                    | int             | 疑似什么等级                                                      |
| info.4.0                                    | int             | 待观测，目前都是 0                                                |
| info.4.0                                    | int             | 颜色（十进制数据，需要自己转十六进制颜色代码）                    |
| info.4.0                                    | string          | 待观测，疑似当前什么等级经验值                                    |
| info.4.0                                    | int             | 待观测，某种枚举                                                  |
| info.5                                      | array           | 待观测                                                            |
| info.5.0                                    | string          | 待观测，大部分为空串，少部分存在数据，不确定用途                  |
| info.5.1                                    | string          | 待观测，大部分为空串，少部分存在数据，不确定用途                  |
| info.6                                      | int             | 待观测，一直都是 0                                                |
| info.7                                      | int             | 疑似大航海类型                                                    |
| info.8                                      | -               | 待观测，一直都是空                                                |
| info.9                                      | array           | 发送时间戳                                                        |
| info.9.ct                                   | string          | 待观测，某十六进制数据                                            |
| info.9.ts                                   | int             | 秒级时间戳，应该是发送时间                                        |
| info.10                                     | int             | 待观测，一直都是 0                                                |
| info.11                                     | int             | 待观测，一直都是 0                                                |
| info.12                                     | -               | 待观测，一直都是空                                                |
| info.13                                     | -               | 待观测，一直都是空                                                |
| info.14                                     | int             | 待观测，一直都是 0                                                |
| info.15                                     | int             | 待观测                                                            |
| info.16                                     | array           | 待观测，疑似某种等级信息                                          |
| info.16.0                                   | int             | 待观测，疑似某种等级信息                                          |&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;cmd&quot;: &quot;DANMU_MSG&quot;, // 命令
  &quot;dm_v2&quot;: &quot;&quot;, // 待观察
  &quot;info&quot;: [
    // 数据信息
    [
      // 弹幕信息
      0, // 待观测，目前全是0
      4, // 弹幕的 mode 字段
      25, // 弹幕的 fontsize 字段
      14893055, // 弹幕颜色（十进制数据，需要自己转十六进制颜色代码）
      1730214752940, // 发送时的 UNIX 毫秒时间戳
      1730214703, // 待观测
      0, // 待观测，目前全是0
      &quot;d136d2ff&quot;, // 一个十六进制数，待观测，可能是颜色
      0, // 待观测，目前全是0
      0, // 待观测，目前全是0
      2, // 待观测，疑似某种类型
      &quot;#19897EFF,#403F388E,#33897EFF&quot;, // 待观测，通常为空字符串，info.0.10 == 5 返回一组十六进制颜色代码
      1, // 消息中是否携带表情，1=是，0=否
      {
        // 表情信息，info.0.12 == 1 时为array，其余时候为字符串
        &quot;bulge_display&quot;: 1, // 某种枚举，待观测
        &quot;emoticon_unique&quot;: &quot;room_30118851_73109&quot;, // 表情符号，类似表情ID性质
        &quot;height&quot;: 162, // 表情高度
        &quot;in_player_area&quot;: 1, // 某种枚举，待观测
        &quot;is_dynamic&quot;: 0, // 是否是动态表情，0=否，1=是
        &quot;url&quot;: &quot;http://i0.hdslb.com/bfs/live/c45b116a620ba68ce4a6d0ce7bcfc629a4cbbf98.png&quot;, // 表情URL图片地址
        &quot;width&quot;: 162 // 表情宽度
      },
      &quot;{}&quot;, // 字符串表示的 JSON Object,目前未观测到数据
      {
        // 弹幕补充信息，大部分数据都可以在里面获取
        &quot;extra&quot;: {
          // 补充信息，json字符串，需要自行转换为对象
          &quot;send_from_me&quot;: false, // 是否是自己发送
          &quot;mode&quot;: 0, // 弹幕模式 (等同于info[0][1])
          &quot;color&quot;: 14893055, // 弹幕颜色 (等同于info[0][3])
          &quot;dm_type&quot;: 1, // 某种枚举，待观测
          &quot;font_size&quot;: 25, // 	字体大小（等同于info[0][2]）
          &quot;player_mode&quot;: 4, // 某种枚举，待观测
          &quot;show_player_type&quot;: 0, // 某种枚举，待观测
          &quot;content&quot;: &quot;[墨镜]这个表情&quot;, // 弹幕文本信息（等同于info[1]）
          &quot;user_hash&quot;: &quot;3510031103&quot;, // 待观测
          &quot;emoticon_unique&quot;: &quot;room_30118851_73109&quot;, // 待观测
          &quot;bulge_display&quot;: 1, // 待观测
          &quot;recommend_score&quot;: 0, // 待观测
          &quot;main_state_dm_color&quot;: &quot;&quot;, // 待观测
          &quot;objective_state_dm_color&quot;: &quot;&quot;, // 待观测
          &quot;direction&quot;: 0, // 待观测
          &quot;pk_direction&quot;: 0, // 待观测
          &quot;quartet_direction&quot;: 0, // 待观测
          &quot;anniversary_crowd&quot;: 0, // 待观测
          &quot;yeah_space_type&quot;: &quot;&quot;, // 待观测
          &quot;yeah_space_url&quot;: &quot;&quot;, // 待观测
          &quot;jump_to_url&quot;: &quot;&quot;, // 待观测
          &quot;space_type&quot;: &quot;&quot;, // 待观测
          &quot;space_url&quot;: &quot;&quot;, // 待观测
          &quot;animation&quot;: {}, // 待观测，目前未观测到数据
          &quot;emots&quot;: {
            // 文本中使用过的表情信息，对象类型，key为表情，例如：[墨镜]
            &quot;[墨镜]&quot;: {
              // 某个表情，key为对应的文本
              &quot;count&quot;: 1, // 文本中出现的数量
              &quot;descript&quot;: &quot;[墨镜]&quot;, // 描述
              &quot;emoji&quot;: &quot;[墨镜]&quot;, // 描述
              &quot;emoticon_id&quot;: 273, // 待观测
              &quot;emoticon_unique&quot;: &quot;emoji_273&quot;, // 待观测
              &quot;height&quot;: 20, // 图片高度
              &quot;url&quot;: &quot;http://i0.hdslb.com/bfs/live/5e01c237642c8b662a69e21b8e0fbe6e7dbc2aa1.png&quot;, // 图片url
              &quot;width&quot;: 20 // 图片宽度
            }
          },
          &quot;is_audited&quot;: false, // 待观测
          &quot;id_str&quot;: &quot;0b0b20c622a374dc3e1e327bb76720fb63&quot;, // 待观测
          &quot;icon&quot;: null, // 待观测
          &quot;show_reply&quot;: true, // 是否允许回复
          &quot;reply_mid&quot;: 0, // 回复消息发送人uid
          &quot;reply_uname&quot;: &quot;&quot;, // 回复消息发送人名称
          &quot;reply_uname_color&quot;: &quot;&quot;, // 回复消息发送人名字颜色
          &quot;reply_is_mystery&quot;: false, // 待观测
          &quot;reply_type_enum&quot;: 0, // 待观测，某种枚举
          &quot;hit_combo&quot;: 0, // 待观测，疑似是一些重内容连续发送的连击数
          &quot;esports_jump_url&quot;: &quot;&quot; // 待观测
        },
        &quot;mode&quot;: 0, // 某种枚举，待观测
        &quot;show_player_type&quot;: 0, // 某种枚举，待观测
        &quot;user&quot;: {
          // 弹幕发送人信息
          &quot;base&quot;: {
            // 发送人基本信息
            &quot;face&quot;: &quot;https://i1.hdslb.com/bfs/face/1bddafc0564fd84e6da7f262c6c37deb6605d623.jpg&quot;, // 头像
            &quot;is_mystery&quot;: false, // 不确定含义，待观测
            &quot;name&quot;: &quot;岚烟y&quot;, // 名称
            &quot;name_color&quot;: 0, // 待观测，估计是名字颜色十进制数字
            &quot;name_color_str&quot;: &quot;&quot;, // 待观测，估计是名字颜色十六进制代码
            &quot;official_info&quot;: {
              // 待观测，估计是官方相关
              &quot;desc&quot;: &quot;&quot;, // ？说明？
              &quot;role&quot;: 0, // ？角色？
              &quot;title&quot;: &quot;&quot;, // ？官方title？
              &quot;type&quot;: -1 // ？类型
            },
            &quot;origin_info&quot;: {
              // 待观测，目前看到的都是发送人个人信息
              &quot;face&quot;: &quot;https://i1.hdslb.com/bfs/face/1bddafc0564fd84e6da7f262c6c37deb6605d623.jpg&quot;, // 头像
              &quot;name&quot;: &quot;岚烟y&quot; // 名称
            },
            &quot;risk_ctrl_info&quot;: {
              // 待观测，目前看到的都是发送人个人信息
              &quot;face&quot;: &quot;https://i1.hdslb.com/bfs/face/1bddafc0564fd84e6da7f262c6c37deb6605d623.jpg&quot;, // 头像
              &quot;name&quot;: &quot;岚烟y&quot; // 名称
            }
          },
          &quot;guard&quot;: null, // 待观测，怀疑可能跟超管有关
          &quot;guard_leader&quot;: {
            // 待观测，怀疑可能跟超管有关
            &quot;is_guard_leader&quot;: false // 待观测，怀疑可能跟超管有关
          },
          &quot;medal&quot;: {
            // 牌子信息
            &quot;color&quot;: 398668, // 牌子颜色（十进制数据，需要自己转十六进制颜色代码）
            &quot;color_border&quot;: 16771156, // 牌子边框颜色（十进制数据，需要自己转十六进制颜色代码）
            &quot;color_end&quot;: 6850801, // 牌子右侧颜色（渐变）（十进制数据，需要自己转十六进制颜色代码）
            &quot;color_start&quot;: 398668, // 牌子左侧颜色（渐变）（十进制数据，需要自己转十六进制颜色代码）
            &quot;guard_icon&quot;: &quot;https://i0.hdslb.com/bfs/live/98a201c14a64e860a758f089144dcf3f42e7038c.png&quot;, // 大航海图标
            &quot;guard_level&quot;: 2, // 大航海类型，0=普通用户，1=总督，2=提督，3=舰长
            &quot;honor_icon&quot;: &quot;&quot;, // 待观测，目前无数据
            &quot;id&quot;: 1130856, // 待观测，目前都是0
            &quot;is_light&quot;: 1, // 牌子是否点亮 ，1=是，0=否
            &quot;level&quot;: 26, // 牌子等级
            &quot;name&quot;: &quot;泠妻&quot;, // 牌子名称
            &quot;ruid&quot;: 3494365156608185, // 牌子所属主播uid
            &quot;score&quot;: 50046632, // 某种评分？待观测
            &quot;typ&quot;: 0, // 待观测，目前都是0
            &quot;user_receive_count&quot;: 0, // 待观测，目前都是0
            &quot;v2_medal_color_border&quot;: &quot;#58A1F8FF&quot;, // v2版本牌子边框颜色
            &quot;v2_medal_color_end&quot;: &quot;#4775EFCC&quot;, // v2版本牌子右侧颜色（渐变）
            &quot;v2_medal_color_level&quot;: &quot;#000B7099&quot;, // v2版本牌子等级颜色
            &quot;v2_medal_color_start&quot;: &quot;#4775EFCC&quot;, // v2版本牌子左侧颜色（渐变）
            &quot;v2_medal_color_text&quot;: &quot;#FFFFFFFF&quot; // v2版本牌子内容颜色
          },
          &quot;title&quot;: {
            // 待观测
            &quot;old_title_css_id&quot;: &quot;&quot;, // 待观测
            &quot;title_css_id&quot;: &quot;&quot; // 待观测
          },
          &quot;uhead_frame&quot;: null, // 待观测，目前无数据
          &quot;uid&quot;: 400970605, // uid
          &quot;wealth&quot;: null // 待观测
        }
      },
      {
        // 疑似活动信息，待观测
        &quot;activity_identity&quot;: &quot;&quot;, // 待观测
        &quot;activity_source&quot;: 0, // 待观测
        &quot;not_show&quot;: 0 // 待观测
      },
      42 // 待观测
    ],
    &quot;[墨镜]这个表情&quot;, // 弹幕信息
    [
      // 用户信息
      400970605, // 用户uid
      &quot;岚烟y&quot;, // 用户名称
      1, // 待观测
      0, // 待观测
      0, // 待观测
      10000, // 待观测
      1, // 待观测
      &quot;#E17AFF&quot; // 待观测，某种颜色信息，有时为空
    ],
    [
      // 牌子信息
      26, // 牌子等级
      &quot;泠妻&quot;, // 牌子名称
      &quot;温以泠&quot;, // 牌子所属主播名称
      30118851, // 牌子所属直播间房间号
      398668, // 牌子颜色（十进制数据，需要自己转十六进制颜色代码）
      &quot;&quot;, // 待观测，一直是空
      0, // 待观测，一直是0
      16771156, // 牌子边框颜色（十进制数据，需要自己转十六进制颜色代码）
      398668, // 牌子左侧颜色（渐变）（十进制数据，需要自己转十六进制颜色代码）
      6850801, // 牌子右侧颜色（渐变）（十进制数据，需要自己转十六进制颜色代码）
      2, // 大航海类型，0=普通用户，1=总督，2=提督，3=舰长
      1, // 牌子是否点亮 ，1=是，0=否
      3494365156608185 // 牌子所属主播uid
    ],
    [
      // 疑似什么等级信息
      15, // 疑似什么等级
      0, // 待观测，目前都是0
      6406234, // 颜色（十进制数据，需要自己转十六进制颜色代码）
      &quot;&gt;50000&quot;, // 待观测，疑似当前什么等级经验值
      0 // 待观测，某种枚举
    ],
    [
      // 待观测
      &quot;&quot;, // 待观测，大部分为空串，少部分存在数据，不确定用途
      &quot;&quot; // 待观测，大部分为空串，少部分存在数据，不确定用途
    ],
    0, // 待观测，一直都是0
    2, // 疑似大航海类型
    null, // 待观测，一直都是空
    {
      // 发送时间戳
      &quot;ct&quot;: &quot;F2A502B4&quot;, // 待观测，某十六进制数据
      &quot;ts&quot;: 1730214752 // 秒级时间戳，应该是发送时间
    },
    0, // 待观测，一直都是0
    0, // 待观测，一直都是0
    null, // 待观测，一直都是空
    null, // 待观测，一直都是空
    0, // 待观测，一直都是0
    594, // 待观测
    [
      // 待观测，疑似某种等级信息
      28 // 待观测，疑似某种等级信息
    ],
    null // 待观测
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;开通大航海:GUARD_BUY&lt;/h3&gt;
&lt;p&gt;| 字段             | 字段类型 | 字段说明                         |
| :--------------- | :------- | :------------------------------- |
| cmd              | string   | 固定命令                         |
| data             | array    | 数据信息                         |
| data.uid         | int      | 用户 uid                         |
| data.username    | string   | 用户名                           |
| data.guard_level | int      | 开通类型，1=总督，2=提督，3=舰长 |
| data.num         | int      | 开通数量                         |
| data.price       | int      | 价值（电池*100）                |
| data.gift_id     | int      | 礼物 ID                          |
| data.gift_name   | int      | 礼物名称                         |
| data.start_time  | int      | 上舰时间                         |
| data.end_time    | int      | 上舰时间                         |&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;cmd&quot;: &quot;GUARD_BUY&quot;, // 固定命令
  &quot;data&quot;: {
    // 数据信息
    &quot;uid&quot;: 24967532, // 用户uid
    &quot;username&quot;: &quot;骸骸家的三哥哥&quot;, // 用户名
    &quot;guard_level&quot;: 3, // 开通类型，1=总督，2=提督，3=舰长
    &quot;num&quot;: 1, // 开通数量
    &quot;price&quot;: 198000, // 价值（电池*100）
    &quot;gift_id&quot;: 10003, // 礼物ID
    &quot;gift_name&quot;: &quot;舰长&quot;, // 礼物名称
    &quot;start_time&quot;: 1730438935, // 上舰时间
    &quot;end_time&quot;: 1730438935 // 上舰时间
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;千舰推送:GUARD_LOTTERY_START&lt;/h3&gt;
&lt;p&gt;| 字段     | 字段类型 | 字段说明              |
| :------- | :------- | :-------------------- |
| cmd      | string   | 固定命令              |
| data     | array    | 数据信息              |
| data.add | array    | 新增千舰主播 uid 数组 |
| data.del | array    | 退出千舰主播 uid 数组 |&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;cmd&quot;: &quot;GUARD_HONOR_THOUSAND&quot;, // 固定命令
  &quot;data&quot;: {
    // 数据信息
    &quot;add&quot;: [
      // 新增千舰
      13164144, // 主播的uid
      433351 // 主播的uid
    ],
    &quot;del&quot;: [
      // 退出千舰
      3537115310721781, // 主播的uid
      3493273152784750 // 主播的uid
    ]
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;上舰抽奖消息推送:USER_TOAST_MSG&lt;/h3&gt;
&lt;p&gt;| 字段                      | 字段类型 | 字段说明                                           |
| :------------------------ | :------- | :------------------------------------------------- |
| cmd                       | string   | 固定命令                                           |
| data                      | array    | 数据信息                                           |
| data.anchor_show          | bool     | 待观测，疑似是否在直播间展示，通常为 true          |
| data.color                | string   | 某种颜色值                                         |
| data.dmscore              | int      | 待观测                                             |
| data.effect_id            | int      | 待观测                                             |
| data.end_time             | int      | 结束时间（秒级时间戳），与开始时间相同             |
| data.face_effect_id       | int      | 待观测                                             |
| data.gift_id              | int      | 礼物 ID                                            |
| data.group_name           | int      | 待观测                                             |
| data.group_op_type        | int      | 待观测                                             |
| data.group_role_name      | int      | 待观测                                             |
| data.guard_level          | int      | 开通大航海类型，0=普通用户，1=总督，2=提督，3=舰长 |
| data.is_group             | int      | 待观测                                             |
| data.is_show              | int      | 待观测                                             |
| data.num                  | int      | 开通数量                                           |
| data.op_type              | int      | 待观测                                             |
| data.payflow_id           | string   | 待观测                                             |
| data.price                | int      | 大航海价格（电池*100）                            |
| data.role_name            | string   | 身份名称                                           |
| data.room_effect_id       | int      | 待观测                                             |
| data.room_gift_effect_id  | int      | 待观测                                             |
| data.room_group_effect_id | int      | 待观测                                             |
| data.source               | int      | 待观测                                             |
| data.start_time           | int      | 开始时间（秒级时间戳），与开始时间相同             |
| data.svga_block           | int      | 待观测                                             |
| data.target_guard_count   | int      | 待观测                                             |
| data.toast_msg            | string   | 在直播间中发送的文字内容                           |
| data.uid                  | int      | 开通用户 uid                                       |
| data.unit                 | string   | 开通单位                                           |
| data.user_show            | bool     | 跟显示相关？待观测                                 |
| data.username             | string   | 开通用户名称                                       |&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;cmd&quot;: &quot;USER_TOAST_MSG&quot;, // 固定命令
  &quot;data&quot;: {
    // 数据信息
    &quot;anchor_show&quot;: true, // 待观测，疑似是否在直播间展示，通常为true
    &quot;color&quot;: &quot;#00D1F1&quot;, // 某种颜色值
    &quot;dmscore&quot;: 306, // 待观测
    &quot;effect_id&quot;: 397, // 待观测
    &quot;end_time&quot;: 1730361519, // 结束时间（秒级时间戳），与开始时间相同
    &quot;face_effect_id&quot;: 44, // 待观测
    &quot;gift_id&quot;: 10003, // 礼物ID
    &quot;group_name&quot;: &quot;&quot;, // 待观测
    &quot;group_op_type&quot;: 0, // 待观测
    &quot;group_role_name&quot;: &quot;&quot;, // 待观测
    &quot;guard_level&quot;: 3, // 开通大航海类型，0=普通用户，1=总督，2=提督，3=舰长
    &quot;is_group&quot;: 0, // 待观测
    &quot;is_show&quot;: 0, // 待观测
    &quot;num&quot;: 1, // 开通数量
    &quot;op_type&quot;: 2, // 待观测
    &quot;payflow_id&quot;: &quot;2410311558284912114936330&quot;, // 待观测
    &quot;price&quot;: 168000, // 大航海价格（电池*100）
    &quot;role_name&quot;: &quot;舰长&quot;, // 身份名称
    &quot;room_effect_id&quot;: 590, // 待观测
    &quot;room_gift_effect_id&quot;: 0, // 待观测
    &quot;room_group_effect_id&quot;: 1337, // 待观测
    &quot;source&quot;: 0, // 待观测
    &quot;start_time&quot;: 1730361519, // 开始时间（秒级时间戳），与开始时间相同
    &quot;svga_block&quot;: 0, // 待观测
    &quot;target_guard_count&quot;: 64, // 待观测
    &quot;toast_msg&quot;: &quot;&amp;#x3C;%尘世七仙%&gt; 在主播温以泠的直播间续费了舰长，今天是TA陪伴主播的第325天&quot;, // 在直播间中发送的文字内容
    &quot;uid&quot;: 1517971493, // 开通用户uid
    &quot;unit&quot;: &quot;月&quot;, // 开通单位
    &quot;user_show&quot;: true, // 跟显示相关？待观测
    &quot;username&quot;: &quot;尘世七仙&quot; // 开通用户名称
  },
  &quot;msg_id&quot;: &quot;21919243995990016:1000:1000&quot;,
  &quot;p_is_ack&quot;: true,
  &quot;p_msg_type&quot;: 1,
  &quot;send_time&quot;: 1730361520068
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;醒目留言:SUPER_CHAT_MESSAGE&lt;/h3&gt;
&lt;p&gt;| 字段                                   | 字段类型 | 字段说明                                                       |
| :------------------------------------- | :------- | :------------------------------------------------------------- |
| cmd                                    | string   | 固定命令                                                       |
| data                                   | array    | 数据信息                                                       |
| data.background_bottom_color           | string   | 底部正文区域背景色                                             |
| data.background_color                  | string   | 底部正文文字颜色                                               |
| data.background_color_end              | string   | 牌子右侧颜色（渐变）                                           |
| data.background_color_start            | string   | 牌子左侧颜色（渐变）                                           |
| data.background_icon                   | string   | ？背景图标？                                                   |
| data.background_image                  | string   | ？背景图片？                                                   |
| data.background_price_color            | string   | ？价格背景颜色？                                               |
| data.color_point                       | int      | 待观测                                                         |
| data.dmscore                           | int      | 待观测                                                         |
| data.end_time                          | int      | 醒目留言结束时间（秒级时间戳）                                 |
| data.gift                              | array    | 礼物信息                                                       |
| data.gift.gift_id                      | int      | 礼物 ID                                                        |
| data.gift.gift_name                    | string   | 礼物名称                                                       |
| data.gift.num                          | int      | 赠送数量                                                       |
| data.group_medal                       | array    | 待观测，疑似套票                                               |
| data.group_medal.is_lighted            | int      | 疑似套票是否点亮，1=是，0=否                                   |
| data.group_medal.medal_id              | int      | 疑似套票 id                                                    |
| data.group_medal.name                  | string   | 疑似套票名称                                                   |
| data.id                                | int      | 疑似醒目留言 ID                                                |
| data.is_mystery                        | bool     | 待观测                                                         |
| data.is_ranked                         | int      | 待观测                                                         |
| data.is_send_audit                     | int      | 待观测                                                         |
| data.medal_info                        | array    | 牌子信息                                                       |
| data.medal_info.anchor_roomid          | int      | 房间号                                                         |
| data.medal_info.anchor_uname           | string   | 主播名称                                                       |
| data.medal_info.guard_level            | int      | 牌子的大航海类型，0=普通用户，1=总督，2=提督，3=舰长           |
| data.medal_info.icon_id                | int      | 应该是某 ID 相关，待观测，目前全是 0                           |
| data.medal_info.is_lighted             | int      | 牌子是否点亮 ，1=是，0=否                                      |
| data.medal_info.medal_color            | string   | 牌子颜色（十六进制颜色代码）                                   |
| data.medal_info.medal_color_border     | int      | 牌子边框颜色（十进制数据，需要自己转十六进制颜色代码）         |
| data.medal_info.medal_color_end        | int      | 牌子右侧颜色（渐变）（十进制数据，需要自己转十六进制颜色代码） |
| data.medal_info.medal_color_start      | int      | 牌子左侧颜色（渐变）（十进制数据，需要自己转十六进制颜色代码） |
| data.medal_info.medal_level            | int      | 牌子等级                                                       |
| data.medal_info.medal_name             | string   | 牌子名称                                                       |
| data.medal_info.special                | -        | 估计是用来区分特殊牌子的，待观测                               |
| data.medal_info.target_id              | int      | 牌子所属主播 uid                                               |
| data.message                           | string   | 醒目留言内容                                                   |
| data.message_font_color                | string   | 醒目留言颜色                                                   |
| data.message_trans                     | string   | 日语信息                                                       |
| data.price                             | int      | 单价？电池/10                                                  |
| data.rate                              | int      | 待观测，会不会是用来计算价格的？比如 price * rate = 电池*100   |
| data.start_time                        | int      | 醒目留言开始时间（秒级时间戳）                                 |
| data.time                              | int      | 醒目留言持续时间                                               |
| data.token                             | string   | 待观测                                                         |
| data.trans_mark                        | int      | 待观测                                                         |
| data.ts                                | int      | 醒目留言开始时间（秒级时间戳）                                 |
| data.uid                               | int      | 发送用户 uid                                                   |
| data.uinfo                             | array    | 发送用户信息                                                   |
| data.uinfo.base                        | array    | 收礼人（主播）基本信息                                         |
| data.uinfo.base.face                   | string   | 头像                                                           |
| data.uinfo.base.is_mystery             | bool     | 不确定含义，待观测                                             |
| data.uinfo.base.name                   | string   | 名称                                                           |
| data.uinfo.base.name_color             | int      | 待观测，估计是名字颜色十进制数字                               |
| data.uinfo.base.name_color_str         | string   | 待观测，估计是名字颜色十六进制代码                             |
| data.uinfo.base.official_info          | array    | 待观测，估计是官方相关                                         |
| data.uinfo.base.official_info.desc     | string   | ？说明？                                                       |
| data.uinfo.base.official_info.role     | int      | ？角色？                                                       |
| data.uinfo.base.official_info.title    | string   | ？官方 title？                                                 |
| data.uinfo.base.official_info.type     | int      | ？类型                                                         |
| data.uinfo.base.origin_info            | array    | 待观测，目前看到的都是主播个人信息                             |
| data.uinfo.base.origin_info.face       | string   | 头像                                                           |
| data.uinfo.base.origin_info.name       | string   | 名称                                                           |
| data.uinfo.base.risk_ctrl_info         | array    | 待观测，目前看到的都是主播个人信息                             |
| data.uinfo.base.risk_ctrl_info.face    | string   | 头像                                                           |
| data.uinfo.base.risk_ctrl_info.name    | string   | 名称                                                           |
| data.uinfo.guard                       | array    | 大航海应该是，如果不是大航海就是 null                          |
| data.uinfo.guard.expired_str           | string   | 到期时间？                                                     |
| data.uinfo.guard.level                 | int      | 大航海类型，0=普通用户，1=总督，2=提督，3=舰长                 |
| data.uinfo.guard_leader                | -        | 待观测，当前未观测到数据                                       |
| data.uinfo.medal                       | array    | 牌子信息                                                       |
| data.uinfo.medal.color                 | int      | 牌子颜色（十进制数据，需要自己转十六进制颜色代码）             |
| data.uinfo.medal.color_border          | int      | 牌子边框颜色（十进制数据，需要自己转十六进制颜色代码）         |
| data.uinfo.medal.color_end             | int      | 牌子右侧颜色（渐变）（十进制数据，需要自己转十六进制颜色代码） |
| data.uinfo.medal.color_start           | int      | 牌子左侧颜色（渐变）（十进制数据，需要自己转十六进制颜色代码） |
| data.uinfo.medal.guard_icon            | string   | 大航海图标                                                     |
| data.uinfo.medal.guard_level           | int      | 大航海类型，0=普通用户，1=总督，2=提督，3=舰长                 |
| data.uinfo.medal.honor_icon            | -        | 待观测，目前无数据                                             |
| data.uinfo.medal.id                    | int      | 待观测，目前都是 0                                             |
| data.uinfo.medal.is_light              | int      | 牌子是否点亮 ，1=是，0=否                                      |
| data.uinfo.medal.level                 | int      | 牌子等级                                                       |
| data.uinfo.medal.name                  | string   | 牌子名称                                                       |
| data.uinfo.medal.ruid                  | int      | 牌子所属主播 uid                                               |
| data.uinfo.medal.score                 | int      | 某种评分？待观测                                               |
| data.uinfo.medal.typ                   | int      | 待观测，目前都是 0                                             |
| data.uinfo.medal.user_receive_count    | int      | 待观测，目前都是 0                                             |
| data.uinfo.medal.v2_medal_color_border | string   | v2 版本牌子边框颜色                                            |
| data.uinfo.medal.v2_medal_color_end    | string   | v2 版本牌子右侧颜色（渐变）                                    |
| data.uinfo.medal.v2_medal_color_level  | int      | v2 版本牌子等级颜色                                            |
| data.uinfo.medal.v2_medal_color_start  | string   | v2 版本牌子左侧颜色（渐变）                                    |
| data.uinfo.medal.v2_medal_color_text   | string   | v2 版本牌子内容颜色                                            |
| data.uinfo.title                       | array    | 待观测，当前未观测到数据                                       |
| data.uinfo.title.old_title_css_id      | string   | 待观测，当前未观测到数据                                       |
| data.uinfo.title.title_css_id          | string   | 待观测，当前未观测到数据                                       |
| data.uinfo.uhead_frame                 | -        | 待观测，当前未观测到数据                                       |
| data.uinfo.uid                         | int      | uid                                                            |
| data.uinfo.wealth                      | -        | 待观测，当前未观测到数据                                       |
| data.user_info                         | array    | 也是用户信息                                                   |
| data.user_info.face                    | string   | 用户头像                                                       |
| data.user_info.face_frame              | string   | 用户头像框                                                     |
| data.user_info.guard_level             | int      | 大航海类型，0=普通用户，1=总督，2=提督，3=舰长                 |
| data.user_info.is_main_vip             | int      | 待观测，看起来是是否是某种，1=是，0=否                         |
| data.user_info.is_svip                 | int      | 待观测，看起来是是否是某种，1=是，0=否                         |
| data.user_info.is_vip                  | int      | 待观测，看起来是是否是某种，1=是，0=否                         |
| data.user_info.level_color             | string   | 用户等级颜色                                                   |
| data.user_info.manager                 | int      | 待观测                                                         |
| data.user_info.name_color              | string   | 用户名字颜色                                                   |
| data.user_info.title                   | string   | 待观测，当前未观测到数据                                       |
| data.user_info.uname                   | string   | 用户名                                                         |
| data.user_info.user_level              | int      | 用户等级                                                       |&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;cmd&quot;: &quot;SUPER_CHAT_MESSAGE&quot;, // 固定命令
  &quot;data&quot;: {
    // 数据信息
    &quot;background_bottom_color&quot;: &quot;#2A60B2&quot;, // 底部正文区域背景色
    &quot;background_color&quot;: &quot;#EDF5FF&quot;, // 底部正文文字颜色
    &quot;background_color_end&quot;: &quot;#405D85&quot;, // 牌子右侧颜色（渐变）
    &quot;background_color_start&quot;: &quot;#3171D2&quot;, // 牌子左侧颜色（渐变）
    &quot;background_icon&quot;: &quot;&quot;, // ？背景图标？
    &quot;background_image&quot;: &quot;&quot;, // ？背景图片？
    &quot;background_price_color&quot;: &quot;#7497CD&quot;, // ？价格背景颜色？
    &quot;color_point&quot;: 0.7, // 待观测
    &quot;dmscore&quot;: 952, // 待观测
    &quot;end_time&quot;: 1730468880, // 醒目留言结束时间（秒级时间戳）
    &quot;gift&quot;: {
      // 礼物信息
      &quot;gift_id&quot;: 12000, // 礼物ID
      &quot;gift_name&quot;: &quot;醒目留言&quot;, // 礼物名称
      &quot;num&quot;: 1 // 赠送数量
    },
    &quot;group_medal&quot;: {
      // 待观测，疑似套票
      &quot;is_lighted&quot;: 0, // 疑似套票是否点亮，1=是，0=否
      &quot;medal_id&quot;: 0, // 疑似套票id
      &quot;name&quot;: &quot;&quot; // 疑似套票名称
    },
    &quot;id&quot;: 10857495, // 疑似醒目留言ID
    &quot;is_mystery&quot;: false, // 待观测
    &quot;is_ranked&quot;: 0, // 待观测
    &quot;is_send_audit&quot;: 0, // 待观测
    &quot;medal_info&quot;: {
      // 牌子信息
      &quot;anchor_roomid&quot;: 22384516, // 房间号
      &quot;anchor_uname&quot;: &quot;呜米&quot;, // 主播名称
      &quot;guard_level&quot;: 3, // 牌子的大航海类型，0=普通用户，1=总督，2=提督，3=舰长
      &quot;icon_id&quot;: 0, // 应该是某ID相关，待观测，目前全是0
      &quot;is_lighted&quot;: 1, // 牌子是否点亮 ，1=是，0=否
      &quot;medal_color&quot;: &quot;#2d0855&quot;, // 牌子颜色（十六进制颜色代码）
      &quot;medal_color_border&quot;: 6809855, // 牌子边框颜色（十进制数据，需要自己转十六进制颜色代码）
      &quot;medal_color_end&quot;: 10329087, // 牌子右侧颜色（渐变）（十进制数据，需要自己转十六进制颜色代码）
      &quot;medal_color_start&quot;: 2951253, // 牌子左侧颜色（渐变）（十进制数据，需要自己转十六进制颜色代码）
      &quot;medal_level&quot;: 29, // 牌子等级
      &quot;medal_name&quot;: &quot;小米星&quot;, // 牌子名称
      &quot;special&quot;: &quot;&quot;, // 估计是用来区分特殊牌子的，待观测
      &quot;target_id&quot;: 617459493 // 牌子所属主播uid
    },
    &quot;message&quot;: &quot;说实话2.0这个皮套当时吸引我留下来的很大原因（小声）后面成功转型为歌粉&quot;, // 醒目留言内容
    &quot;message_font_color&quot;: &quot;#A3F6FF&quot;, // 醒目留言颜色
    &quot;message_trans&quot;: &quot;&quot;, // 日语信息
    &quot;price&quot;: 30, // 单价？电池/10
    &quot;rate&quot;: 1000, // 待观测，会不会是用来计算价格的？比如 price * rate = 电池*100
    &quot;start_time&quot;: 1730468820, // 醒目留言开始时间（秒级时间戳）
    &quot;time&quot;: 60, // 醒目留言持续时间
    &quot;token&quot;: &quot;5D8E7A9D&quot;, // 待观测
    &quot;trans_mark&quot;: 0, // 待观测
    &quot;ts&quot;: 1730468820, // 醒目留言开始时间（秒级时间戳）
    &quot;uid&quot;: 15589465, // 发送用户uid
    &quot;uinfo&quot;: {
      // 发送用户信息
      &quot;base&quot;: {
        // 基本信息
        &quot;face&quot;: &quot;https://i1.hdslb.com/bfs/face/fad0fa8d7cb85f82fde486fc49b8766e00d7272b.jpg&quot;, // 头像URL
        &quot;is_mystery&quot;: false, // 不确定含义，待观测
        &quot;name&quot;: &quot;唯心的狐狸&quot;, // 名称
        &quot;name_color&quot;: 0, // 待观测，估计是名字颜色十进制数字
        &quot;name_color_str&quot;: &quot;#00D1F1&quot;, // 待观测，估计是名字颜色十六进制代码
        &quot;official_info&quot;: {
          // 待观测，估计是官方相关
          &quot;desc&quot;: &quot;&quot;, // ？说明？
          &quot;role&quot;: 0, // ？角色？
          &quot;title&quot;: &quot;&quot;, // ？官方title？
          &quot;type&quot;: -1 // ？类型？
        },
        &quot;origin_info&quot;: {
          // 待观测，目前看到的都是用户个人信息
          &quot;face&quot;: &quot;https://i1.hdslb.com/bfs/face/fad0fa8d7cb85f82fde486fc49b8766e00d7272b.jpg&quot;, // 头像URL
          &quot;name&quot;: &quot;唯心的狐狸&quot; // 名称
        },
        &quot;risk_ctrl_info&quot;: {
          // 待观测，目前看到的都是用户个人信息
          &quot;face&quot;: &quot;https://i1.hdslb.com/bfs/face/fad0fa8d7cb85f82fde486fc49b8766e00d7272b.jpg&quot;, // 头像URL
          &quot;name&quot;: &quot;唯心的狐狸&quot; // 名称
        }
      },
      &quot;guard&quot;: {
        // 大航海应该是，如果不是大航海就是null
        &quot;expired_str&quot;: &quot;2025-11-14 23:59:59&quot;, // 到期时间
        &quot;level&quot;: 3 // 大航海类型，0=普通用户，1=总督，2=提督，3=舰长
      },
      &quot;guard_leader&quot;: null, // 房管的领导？
      &quot;medal&quot;: {
        // 牌子信息
        &quot;color&quot;: 2951253, // 牌子颜色（十进制数据，需要自己转十六进制颜色代码）
        &quot;color_border&quot;: 6809855, // 牌子边框颜色（十进制数据，需要自己转十六进制颜色代码）
        &quot;color_end&quot;: 10329087, // 牌子右侧颜色（渐变）（十进制数据，需要自己转十六进制颜色代码）
        &quot;color_start&quot;: 2951253, // 牌子左侧颜色（渐变）（十进制数据，需要自己转十六进制颜色代码）
        &quot;guard_icon&quot;: &quot;https://i0.hdslb.com/bfs/live/143f5ec3003b4080d1b5f817a9efdca46d631945.png&quot;, // 大航海图标URL
        &quot;guard_level&quot;: 3, // 大航海类型，0=普通用户，1=总督，2=提督，3=舰长
        &quot;honor_icon&quot;: &quot;&quot;, // 待观测，目前无数据
        &quot;id&quot;: 0, // 待观测，目前都是0
        &quot;is_light&quot;: 1, // 牌子是否点亮 ，1=是，0=否
        &quot;level&quot;: 29, // 牌子等级
        &quot;name&quot;: &quot;小米星&quot;, // 牌子名称
        &quot;ruid&quot;: 617459493, // 牌子所属主播uid
        &quot;score&quot;: 50505386, // 某种评分？待观测
        &quot;typ&quot;: 0, // 待观测，目前都是0
        &quot;user_receive_count&quot;: 0, // 待观测，目前都是0
        &quot;v2_medal_color_border&quot;: &quot;#D47AFFFF&quot;, // v2版本牌子边框颜色
        &quot;v2_medal_color_end&quot;: &quot;#9660E5CC&quot;, // v2版本牌子右侧颜色（渐变）
        &quot;v2_medal_color_level&quot;: &quot;#6C00A099&quot;, // v2版本牌子等级颜色
        &quot;v2_medal_color_start&quot;: &quot;#9660E5CC&quot;, // v2版本牌子左侧颜色（渐变）
        &quot;v2_medal_color_text&quot;: &quot;#FFFFFFFF&quot; // v2版本牌子内容颜色
      },
      &quot;title&quot;: {
        // 待观测，当前未观测到数据
        &quot;old_title_css_id&quot;: &quot;&quot;, // 待观测，当前未观测到数据
        &quot;title_css_id&quot;: &quot;&quot; // 待观测，当前未观测到数据
      },
      &quot;uhead_frame&quot;: null, // 待观测，当前未观测到数据
      &quot;uid&quot;: 15589465, // 用户uid
      &quot;wealth&quot;: null // 待观测，当前未观测到数据
    },
    &quot;user_info&quot;: {
      // 也是用户信息
      &quot;face&quot;: &quot;https://i1.hdslb.com/bfs/face/fad0fa8d7cb85f82fde486fc49b8766e00d7272b.jpg&quot;, // 用户头像
      &quot;face_frame&quot;: &quot;https://i0.hdslb.com/bfs/live/80f732943cc3367029df65e267960d56736a82ee.png&quot;, // 用户头像框
      &quot;guard_level&quot;: 3, // 大航海类型，0=普通用户，1=总督，2=提督，3=舰长
      &quot;is_main_vip&quot;: 1, // 待观测，看起来是是否是某种，1=是，0=否
      &quot;is_svip&quot;: 0, // 待观测，看起来是是否是某种，1=是，0=否
      &quot;is_vip&quot;: 0, // 待观测，看起来是是否是某种，1=是，0=否
      &quot;level_color&quot;: &quot;#5896de&quot;, // 用户等级颜色
      &quot;manager&quot;: 0, // 待观测
      &quot;name_color&quot;: &quot;#00D1F1&quot;, // 用户名字颜色
      &quot;title&quot;: &quot;&quot;, // 待观测，当前未观测到数据
      &quot;uname&quot;: &quot;唯心的狐狸&quot;, // 用户名
      &quot;user_level&quot;: 24 // 用户等级
    }
  },
  &quot;is_report&quot;: true,
  &quot;msg_id&quot;: &quot;21975500356352512:1000:1000&quot;,
  &quot;p_is_ack&quot;: true,
  &quot;p_msg_type&quot;: 1,
  &quot;send_time&quot;: 1730468820560
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;舰长进入直播间:ENTRY_EFFECT&lt;/h3&gt;
&lt;p&gt;| 字段                                   | 字段类型 | 字段说明                                                       |
| :------------------------------------- | :------- | :------------------------------------------------------------- |
| cmd                                    | string   | 固定命令                                                       |
| data                                   | array    | 数据信息                                                       |
| data.basemap_url                       | string   | 欢迎进入时的背景图                                             |
| data.business                          | int      | 待观测，像是区分了欢迎背景图的类型                             |
| data.copy_color                        | string   | 文字颜色                                                       |
| data.copy_writing                      | string   | 网页中出现的欢迎文案                                           |
| data.copy_writing_v2                   | string   | 网页中出现的欢迎文案                                           |
| data.effect_silent_time                | int      | 待观测                                                         |
| data.effective_time                    | int      | 有效时间，单位应该是秒                                         |
| data.effective_time_new                | int      | 待观测                                                         |
| data.face                              | string   | 用户头像                                                       |
| data.full_cartoon_id                   | int      | 待观测                                                         |
| data.highlight_color                   | string   | 文字突出颜色                                                   |
| data.icon_list                         | array    | 待观测                                                         |
| data.icon_list.0                       | -        | 待观测                                                         |
| data.id                                | int      | id                                                             |
| data.identities                        | int      | 待观测                                                         |
| data.is_mystery                        | bool     | 待观测                                                         |
| data.max_delay_time                    | int      | 疑似最长停留时间？                                             |
| data.mobile_dynamic_url_webp           | int      | 待观测                                                         |
| data.mock_effect                       | int      | 待观测                                                         |
| data.new_style                         | int      | 待观测                                                         |
| data.priority                          | int      | 优先级，应该是数字越大越靠前                                   |
| data.priority_level                    | xxxxx    | xxxxx                                                          |
| data.privilege_type                    | int      | 特权类型，0=普通用户，1=总督，2=提督，3=舰长                   |
| data.show_avatar                       | int      | 是否显示头像，0=否，1=是                                       |
| data.target_id                         | int      | 主播 uid                                                       |
| data.trigger_time                      | int      | 待观测                                                         |
| data.uid                               | int      | 用户 uid                                                       |
| data.uinfo                             | array    | 用户信息                                                       |
| data.uinfo.base                        | array    | 基本信息                                                       |
| data.uinfo.base.face                   | string   | 头像                                                           |
| data.uinfo.base.is_mystery             | bool     | 待观测                                                         |
| data.uinfo.base.name                   | string   | 名称                                                           |
| data.uinfo.base.name_color             | int      | 待观测                                                         |
| data.uinfo.base.name_color_str         | string   | 名字颜色？                                                     |
| data.uinfo.base.official_info          | -        | 待观测                                                         |
| data.uinfo.base.origin_info            | -        | 待观测                                                         |
| data.uinfo.base.risk_ctrl_info         | -        | 待观测                                                         |
| data.uinfo.guard                       | array    | 大航海信息                                                     |
| data.uinfo.guard.expired_str           | string   | 到期日期                                                       |
| data.uinfo.guard.level                 | int      | 大航海类型，0=普通用户，1=总督，2=提督，3=舰长                 |
| data.uinfo.guard_leader                | -        | 待观测                                                         |
| data.uinfo.medal                       | array    | 牌子信息                                                       |
| data.uinfo.medal.color                 | int      | 牌子颜色（十进制数据，需要自己转十六进制颜色代码）             |
| data.uinfo.medal.color_border          | int      | 牌子边框颜色（十进制数据，需要自己转十六进制颜色代码）         |
| data.uinfo.medal.color_end             | int      | 牌子右侧颜色（渐变）（十进制数据，需要自己转十六进制颜色代码） |
| data.uinfo.medal.color_start           | int      | 牌子左侧颜色（渐变）（十进制数据，需要自己转十六进制颜色代码） |
| data.uinfo.medal.guard_icon            | string   | 大航海图标 URL                                                 |
| data.uinfo.medal.guard_level           | int      | 大航海类型，0=普通用户，1=总督，2=提督，3=舰长                 |
| data.uinfo.medal.honor_icon            | string   | 待观测，目前无数据                                             |
| data.uinfo.medal.id                    | int      | 待观测                                                         |
| data.uinfo.medal.is_light              | int      | 牌子是否点亮 ，1=是，0=否                                      |
| data.uinfo.medal.level                 | int      | 牌子等级                                                       |
| data.uinfo.medal.name                  | int      | 牌子名称                                                       |
| data.uinfo.medal.ruid                  | int      | 主播 uid                                                       |
| data.uinfo.medal.score                 | int      | 某种评分？待观测                                               |
| data.uinfo.medal.typ                   | int      | 待观测，目前都是 0                                             |
| data.uinfo.medal.user_receive_count    | int      | 待观测，目前都是 0                                             |
| data.uinfo.medal.v2_medal_color_border | string   | v2 版本牌子边框颜色                                            |
| data.uinfo.medal.v2_medal_color_end    | string   | v2 版本牌子右侧颜色（渐变）                                    |
| data.uinfo.medal.v2_medal_color_level  | string   | v2 版本牌子等级颜色                                            |
| data.uinfo.medal.v2_medal_color_start  | string   | v2 版本牌子左侧颜色（渐变）                                    |
| data.uinfo.medal.v2_medal_color_text   | string   | v2 版本牌子内容颜色                                            |
| data.uinfo.title                       | -        | 待观测                                                         |
| data.uinfo.uhead_frame                 | array    | 待观测                                                         |
| data.uinfo.uhead_frame.frame_img       | string   | 待观测                                                         |
| data.uinfo.uhead_frame.id              | int      | 待观测                                                         |
| data.uinfo.uid                         | int      | 用户 uid                                                       |
| data.uinfo.wealth                      | array    | 荣耀等级                                                       |
| data.uinfo.wealth.dm_icon_key          | int      | 荣耀等级                                                       |
| data.uinfo.wealth.level                | string   | 待观测                                                         |
| data.wealth_style_info                 | array    | 荣耀等级图片                                                   |
| data.wealth_style_info.url             | string   | 荣耀等级图片 URL                                               |
| data.wealthy_info                      | array    | 荣耀等级信息                                                   |
| data.wealthy_info.cur_score            | int      | 待观测                                                         |
| data.wealthy_info.dm_icon_key          | int      | 待观测                                                         |
| data.wealthy_info.level                | int      | 荣耀等级                                                       |
| data.wealthy_info.level_total_score    | int      | 待观测                                                         |
| data.wealthy_info.status               | int      | 待观测                                                         |
| data.wealthy_info.uid                  | int      | 待观测                                                         |
| data.wealthy_info.upgrade_need_score   | int      | 待观测                                                         |
| data.web_basemap_url                   | string   | web 版欢迎进入时的背景图                                       |
| data.web_close_time                    | int      | 待观测                                                         |
| data.web_dynamic_url_apng              | int      | 待观测                                                         |
| data.web_dynamic_url_webp              | int      | 待观测                                                         |
| data.web_effect_close                  | int      | 待观测                                                         |
| data.web_effective_time                | int      | web 版有效时间，单位应该是秒                                   |&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;cmd&quot;: &quot;ENTRY_EFFECT&quot;,
  &quot;data&quot;: {
    &quot;id&quot;: 381, // id
    &quot;uid&quot;: 28726406, // 用户uid
    &quot;target_id&quot;: 617459493, // 主播uid
    &quot;mock_effect&quot;: 0, // 待观测
    &quot;face&quot;: &quot;https://i2.hdslb.com/bfs/face/cbbe6b3ebf859640afd9fd67897ea6089dea8b51.webp&quot;, // 用户头像
    &quot;privilege_type&quot;: 3, // 特权类型，0=普通用户，1=总督，2=提督，3=舰长
    &quot;copy_writing&quot;: &quot;&amp;#x3C;%ユリが一番好き%&gt; 来了&quot;, // 网页中出现的欢迎文案
    &quot;copy_color&quot;: &quot;#F7F7F7&quot;, // 文字颜色
    &quot;highlight_color&quot;: &quot;#FFFFFF&quot;, // 文字突出颜色
    &quot;priority&quot;: 1, // 优先级，应该是数字越大越靠前
    &quot;basemap_url&quot;: &quot;https://i0.hdslb.com/bfs/live/mlive/aee950e6aacddd0b125506f0a47d7fc1695d3ece.png&quot;, // 欢迎进入时的背景图
    &quot;show_avatar&quot;: 0, // 是否显示头像，0=否，1=是
    &quot;effective_time&quot;: 4, // 有效时间，单位应该是秒
    &quot;web_basemap_url&quot;: &quot;https://i0.hdslb.com/bfs/live/mlive/aee950e6aacddd0b125506f0a47d7fc1695d3ece.png&quot;, // web版欢迎进入时的背景图
    &quot;web_effective_time&quot;: 4, // web版有效时间，单位应该是秒
    &quot;web_effect_close&quot;: 1, // 待观测
    &quot;web_close_time&quot;: 900, // 待观测
    &quot;business&quot;: 6, // 待观测，像是区分了欢迎背景图的类型
    &quot;copy_writing_v2&quot;: &quot;&amp;#x3C;%ユリが一番好…%&gt; 来了&quot;, // 网页中出现的欢迎文案
    &quot;icon_list&quot;: [
      // 待观测
    ],
    &quot;max_delay_time&quot;: 7, // 疑似最长停留时间？
    &quot;trigger_time&quot;: 1730559246176328524, // 待观测
    &quot;identities&quot;: 1, // 待观测
    &quot;effect_silent_time&quot;: 0, // 待观测
    &quot;effective_time_new&quot;: 0, // 待观测
    &quot;web_dynamic_url_webp&quot;: &quot;&quot;, // 待观测
    &quot;web_dynamic_url_apng&quot;: &quot;&quot;, // 待观测
    &quot;mobile_dynamic_url_webp&quot;: &quot;&quot;, // 待观测
    &quot;wealthy_info&quot;: {
      // 荣耀等级信息
      &quot;uid&quot;: 0, // 待观测
      &quot;level&quot;: 28, // 荣耀等级
      &quot;level_total_score&quot;: 0, // 待观测
      &quot;cur_score&quot;: 0, // 待观测
      &quot;upgrade_need_score&quot;: 0, // 待观测
      &quot;status&quot;: 0, // 待观测
      &quot;dm_icon_key&quot;: &quot;&quot; // 待观测
    },
    &quot;new_style&quot;: 1, // 待观测
    &quot;is_mystery&quot;: false, // 待观测
    &quot;uinfo&quot;: {
      // 用户信息
      &quot;uid&quot;: 28726406, // 用户uid
      &quot;base&quot;: {
        // 基本信息
        &quot;name&quot;: &quot;ユリが一番好き&quot;, // 名称
        &quot;face&quot;: &quot;https://i2.hdslb.com/bfs/face/cbbe6b3ebf859640afd9fd67897ea6089dea8b51.webp&quot;, // 头像
        &quot;name_color&quot;: 0, // 待观测
        &quot;is_mystery&quot;: false, // 待观测
        &quot;risk_ctrl_info&quot;: null, // 待观测
        &quot;origin_info&quot;: null, // 待观测
        &quot;official_info&quot;: null, // 待观测
        &quot;name_color_str&quot;: &quot;#00D1F1&quot; // 名字颜色？
      },
      &quot;medal&quot;: {
        // 牌子信息
        &quot;name&quot;: &quot;小米星&quot;, // 牌子名称
        &quot;level&quot;: 25, // 牌子等级
        &quot;color_start&quot;: 398668, // 牌子左侧颜色（渐变）（十进制数据，需要自己转十六进制颜色代码）
        &quot;color_end&quot;: 6850801, // 牌子右侧颜色（渐变）（十进制数据，需要自己转十六进制颜色代码）
        &quot;color_border&quot;: 6809855, // 牌子边框颜色（十进制数据，需要自己转十六进制颜色代码）
        &quot;color&quot;: 398668, // 牌子颜色（十进制数据，需要自己转十六进制颜色代码）
        &quot;id&quot;: 310969, // 待观测
        &quot;typ&quot;: 0, // 待观测，目前都是0
        &quot;is_light&quot;: 1, // 牌子是否点亮 ，1=是，0=否
        &quot;ruid&quot;: 617459493, // 主播uid
        &quot;guard_level&quot;: 3, // 大航海类型，0=普通用户，1=总督，2=提督，3=舰长
        &quot;score&quot;: 50027800, // 某种评分？待观测
        &quot;guard_icon&quot;: &quot;https://i0.hdslb.com/bfs/live/143f5ec3003b4080d1b5f817a9efdca46d631945.png&quot;, // 大航海图标URL
        &quot;honor_icon&quot;: &quot;&quot;, // 待观测，目前无数据
        &quot;v2_medal_color_start&quot;: &quot;#4775EFCC&quot;, // v2版本牌子左侧颜色（渐变）
        &quot;v2_medal_color_end&quot;: &quot;#4775EFCC&quot;, // v2版本牌子右侧颜色（渐变）
        &quot;v2_medal_color_border&quot;: &quot;#58A1F8FF&quot;, // v2版本牌子边框颜色
        &quot;v2_medal_color_text&quot;: &quot;#FFFFFFFF&quot;, // v2版本牌子内容颜色
        &quot;v2_medal_color_level&quot;: &quot;#000B7099&quot;, // v2版本牌子等级颜色
        &quot;user_receive_count&quot;: 0 // 待观测，目前都是0
      },
      &quot;wealth&quot;: {
        // 荣耀等级
        &quot;level&quot;: 28, // 荣耀等级
        &quot;dm_icon_key&quot;: &quot;&quot; // 待观测
      },
      &quot;title&quot;: null, // 待观测
      &quot;guard&quot;: {
        // 大航海信息
        &quot;level&quot;: 3, // 大航海类型，0=普通用户，1=总督，2=提督，3=舰长
        &quot;expired_str&quot;: &quot;2024-11-17 23:59:59&quot; // 到期日期
      },
      &quot;uhead_frame&quot;: null, // 待观测
      &quot;guard_leader&quot;: null // 待观测
    },
    &quot;full_cartoon_id&quot;: 0, // 待观测
    &quot;priority_level&quot;: 0, // 待观测
    &quot;wealth_style_info&quot;: {
      // 荣耀等级图片
      &quot;url&quot;: &quot;https://i0.hdslb.com/bfs/live/62fe89aef112353cfd97016b4b2cc653438642ac.png&quot; // 荣耀等级图片URL
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;直播间互动:INTERACT_WORD&lt;/h3&gt;
&lt;p&gt;| 字段                                   | 字段类型 | 字段说明                                                       |
| :------------------------------------- | :------- | :------------------------------------------------------------- |
| cmd                                    | string   | 固定命令                                                       |
| data                                   | array    | 数据信息                                                       |
| data.contribution                      | array    | ？贡献？                                                       |
| data.contribution.grade                | int      | 待观测，看起来都是 0                                           |
| data.contribution_v2                   | array    | ？贡献 V2 版本？                                               |
| data.contribution_v2.grade             | int      | 待观测，看起来都是 0                                           |
| data.contribution_v2.rank_type         | string   | 待观测，看起来都是空字符串                                     |
| data.contribution_v2.text              | string   | 待观测，看起来都是空字符串                                     |
| data.core_user_type                    | int      | 某种用户类型，等待观测                                         |
| data.dmscore                           | int      | 待观测                                                         |
| data.fans_medal                        | array    | 牌子信息                                                       |
| data.fans_medal.anchor_roomid          | int      | 牌子主播房间号                                                 |
| data.fans_medal.guard_level            | int      | 大航海类型                                                     |
| data.fans_medal.icon_id                | int      | 待观测，某种 ID                                                |
| data.fans_medal.is_lighted             | int      | 是否点亮，1=是，0=否                                           |
| data.fans_medal.medal_color            | int      | 牌子颜色（十进制数据，需要自己转十六进制颜色代码）             |
| data.fans_medal.medal_color_border     | int      | 牌子边框颜色（十进制数据，需要自己转十六进制颜色代码）         |
| data.fans_medal.medal_color_end        | int      | 牌子右侧颜色（渐变）（十进制数据，需要自己转十六进制颜色代码） |
| data.fans_medal.medal_color_start      | int      | 牌子左侧颜色（渐变）（十进制数据，需要自己转十六进制颜色代码） |
| data.fans_medal.medal_level            | int      | 牌子等级                                                       |
| data.fans_medal.medal_name             | string   | 牌子名称                                                       |
| data.fans_medal.score                  | int      | 待观测，某种分数                                               |
| data.fans_medal.special                | string   | 待观测                                                         |
| data.fans_medal.target_id              | int      | 牌子主播 id                                                    |
| data.group_medal                       | array    | 套票信息                                                       |
| data.group_medal.is_lighted            | int      | 是否点亮                                                       |
| data.group_medal.medal_id              | int      | 套票 id                                                        |
| data.group_medal.name                  | string   | 套票名称                                                       |
| data.identities                        | array    | 待观测，？某种身份？                                           |
| data.is_mystery                        | bool     | 待观测，疑似是否是未登陆用户                                   |
| data.is_spread                         | int      | 是否是推广用户，1=是，0=否                                     |
| data.msg_type                          | int      | 消息类型，1=进入直播间，2=关注，3=分享直播间                   |
| data.privilege_type                    | int      | 大航海类型，0=普通用户，1=总督，2=提督，3=舰长                 |
| data.relation_tail                     | array    | 尾部说明                                                       |
| data.relation_tail.tail_guide_text     | string   | 指导文本                                                       |
| data.relation_tail.tail_icon           | string   | 指导图标                                                       |
| data.relation_tail.tail_type           | int      | 内容类型                                                       |
| data.roomid                            | int      | 直播间房间号                                                   |
| data.score                             | int      | 待观测                                                         |
| data.spread_desc                       | string   | 推广来源                                                       |
| data.spread_info                       | string   | 展示推广来源文字颜色                                           |
| data.tail_icon                         | int      | 待观测                                                         |
| data.tail_text                         | string   | 待观测                                                         |
| data.timestamp                         | int      | 互动时间（秒级时间戳）                                         |
| data.trigger_time                      | int      | 待观测                                                         |
| data.uid                               | int      | 用户 uid                                                       |
| data.uinfo                             | array    | 用户信息                                                       |
| data.uinfo.base                        | array    | 基本信息                                                       |
| data.uinfo.base.face                   | string   | 用户头像                                                       |
| data.uinfo.base.is_mystery             | bool     | 待观测，疑似是否是未登陆用户                                   |
| data.uinfo.base.name                   | string   | 名称                                                           |
| data.uinfo.base.name_color             | int      | 名字颜色十进制数字                                             |
| data.uinfo.base.name_color_str         | string   | 名字颜色十六进制代码                                           |
| data.uinfo.base.official_info          | array    | 待观测，估计是官方相关                                         |
| data.uinfo.base.official_info.desc     | string   | ？说明？                                                       |
| data.uinfo.base.official_info.role     | int      | ？角色？                                                       |
| data.uinfo.base.official_info.title    | string   | ？官方 title？                                                 |
| data.uinfo.base.official_info.type     | int      | ？类型？                                                       |
| data.uinfo.base.origin_info            | array    | 待观测，目前看到的都是用户个人信息                             |
| data.uinfo.base.origin_info.face       | string   | 头像                                                           |
| data.uinfo.base.origin_info.name       | string   | 名称                                                           |
| data.uinfo.base.risk_ctrl_info         | array    | 待观测，目前看到的都是用户个人信息                             |
| data.uinfo.base.risk_ctrl_info.face    | string   | 头像                                                           |
| data.uinfo.base.risk_ctrl_info.name    | string   | 名称                                                           |
| data.uinfo.guard                       | array    | 大航海信息                                                     |
| data.uinfo.guard.expired_str           | string   | 到期日期                                                       |
| data.uinfo.guard.level                 | int      | 大航海类型，0=普通用户，1=总督，2=提督，3=舰长                 |
| data.uinfo.guard_leader                | -        | 待观测                                                         |
| data.uinfo.medal                       | array    | 牌子信息                                                       |
| data.uinfo.medal.color                 | int      | 牌子颜色（十进制数据，需要自己转十六进制颜色代码）             |
| data.uinfo.medal.color_border          | int      | 牌子边框颜色（十进制数据，需要自己转十六进制颜色代码）         |
| data.uinfo.medal.color_end             | int      | 牌子右侧颜色（渐变）（十进制数据，需要自己转十六进制颜色代码） |
| data.uinfo.medal.color_start           | int      | 牌子左侧颜色（渐变）（十进制数据，需要自己转十六进制颜色代码） |
| data.uinfo.medal.guard_icon            | string   | 大航海图标                                                     |
| data.uinfo.medal.guard_level           | int      | 大航海类型，0=普通用户，1=总督，2=提督，3=舰长                 |
| data.uinfo.medal.honor_icon            | string   | 待观测，目前无数据                                             |
| data.uinfo.medal.id                    | int      | 待观测，目前都是 0                                             |
| data.uinfo.medal.is_light              | int      | 牌子是否点亮，1=是，0=否                                       |
| data.uinfo.medal.level                 | int      | 牌子等级                                                       |
| data.uinfo.medal.name                  | string   | 牌子名称                                                       |
| data.uinfo.medal.ruid                  | int      | 牌子所属主播 uid                                               |
| data.uinfo.medal.score                 | int      | 某种评分？待观测                                               |
| data.uinfo.medal.typ                   | int      | 待观测，目前都是 0                                             |
| data.uinfo.medal.user_receive_count    | int      | 待观测，目前都是 0                                             |
| data.uinfo.medal.v2_medal_color_border | string   | v2 版本牌子边框颜色                                            |
| data.uinfo.medal.v2_medal_color_end    | string   | v2 版本牌子右侧颜色（渐变）                                    |
| data.uinfo.medal.v2_medal_color_level  | string   | v2 版本牌子等级颜色                                            |
| data.uinfo.medal.v2_medal_color_start  | string   | v2 版本牌子左侧颜色（渐变）                                    |
| data.uinfo.medal.v2_medal_color_text   | string   | v2 版本牌子内容颜色                                            |
| data.uinfo.title                       | -        | 待观测                                                         |
| data.uinfo.uhead_frame                 | array    | 头像框信息                                                     |
| data.uinfo.uhead_frame.frame_img       | string   | 头像框 URL                                                     |
| data.uinfo.uhead_frame.id              | int      | 头像框 ID                                                      |
| data.uinfo.uid                         | int      | 用户 uid                                                       |
| data.uinfo.wealth                      | array    | 待观测                                                         |
| data.uinfo.wealth.dm_icon_key          | string   | 待观测                                                         |
| data.uinfo.wealth.level                | int      | 待观测                                                         |
| data.uname                             | string   | 用户名                                                         |
| data.uname_color                       | string   | 用户名颜色                                                     |&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;cmd&quot;: &quot;INTERACT_WORD&quot;, // 固定命令
  &quot;data&quot;: {
    // 数据信息
    &quot;contribution&quot;: {
      // ？贡献？
      &quot;grade&quot;: 0 // 待观测，看起来都是0
    },
    &quot;contribution_v2&quot;: {
      // ？贡献V2版本？
      &quot;grade&quot;: 0, // 待观测，看起来都是0
      &quot;rank_type&quot;: &quot;&quot;, // 待观测，看起来都是空字符串
      &quot;text&quot;: &quot;&quot; // 待观测，看起来都是空字符串
    },
    &quot;core_user_type&quot;: 3, // 某种用户类型，等待观测
    &quot;dmscore&quot;: 22, // 待观测
    &quot;fans_medal&quot;: {
      // 牌子信息
      &quot;anchor_roomid&quot;: 22384516, // 牌子主播房间号
      &quot;guard_level&quot;: 0, // 大航海类型
      &quot;icon_id&quot;: 0, // 待观测，某种ID
      &quot;is_lighted&quot;: 0, // 是否点亮，1=是，0=否
      &quot;medal_color&quot;: 398668, // 牌子颜色（十进制数据，需要自己转十六进制颜色代码）
      &quot;medal_color_border&quot;: 6809855, // 牌子边框颜色（十进制数据，需要自己转十六进制颜色代码）
      &quot;medal_color_end&quot;: 6850801, // 牌子右侧颜色（渐变）（十进制数据，需要自己转十六进制颜色代码）
      &quot;medal_color_start&quot;: 398668, // 牌子左侧颜色（渐变）（十进制数据，需要自己转十六进制颜色代码）
      &quot;medal_level&quot;: 10, // 牌子等级
      &quot;medal_name&quot;: &quot;小米星&quot;, // 牌子名称
      &quot;score&quot;: 13082, // 待观测，某种分数
      &quot;special&quot;: &quot;&quot;, // 待观测
      &quot;target_id&quot;: 617459493 // 牌子主播id
    },
    &quot;group_medal&quot;: {
      // 套票信息
      &quot;is_lighted&quot;: 1, // 是否点亮
      &quot;medal_id&quot;: 9, // 套票id
      &quot;name&quot;: &quot;MeUmy&quot; // 套票名称
    },
    &quot;identities&quot;: [
      // 待观测，？某种身份？
      1
    ],
    &quot;is_mystery&quot;: false, // 待观测，疑似是否是未登陆用户
    &quot;is_spread&quot;: 1, // 是否是推广用户，1=是，0=否
    &quot;msg_type&quot;: 1, // 消息类型，1=进入直播间，2=关注，3=分享直播间
    &quot;privilege_type&quot;: 0, // 大航海类型，0=普通用户，1=总督，2=提督，3=舰长
    &quot;relation_tail&quot;: {
      // 尾部说明
      &quot;tail_guide_text&quot;: &quot;曾经活跃过，近期与你互动较少&quot;, // 指导文本
      &quot;tail_icon&quot;: &quot;https://i0.hdslb.com/bfs/live/bb88734558c6383a4cfb5fa16c9749d5290d95e8.png&quot;, // 指导图标
      &quot;tail_type&quot;: 4 // 内容类型
    },
    &quot;roomid&quot;: 22384516, // 直播间房间号
    &quot;score&quot;: 1730642535456, // 待观测
    &quot;spread_desc&quot;: &quot;流量包推广&quot;, // 推广来源
    &quot;spread_info&quot;: &quot;#FF649E&quot;, // 展示推广来源文字颜色
    &quot;tail_icon&quot;: 0, // 待观测
    &quot;tail_text&quot;: &quot;&quot;, // 待观测
    &quot;timestamp&quot;: 1730642525, // 互动时间（秒级时间戳）
    &quot;trigger_time&quot;: 1730642525444320300, // 待观测
    &quot;uid&quot;: 439150369, // 用户uid
    &quot;uinfo&quot;: {
      // 用户信息
      &quot;base&quot;: {
        // 基本信息
        &quot;face&quot;: &quot;https://i1.hdslb.com/bfs/face/6c3ff0055a77286e2d0cdf7a27413fb207906c26.jpg&quot;, // 用户头像
        &quot;is_mystery&quot;: false, // 待观测，疑似是否是未登陆用户
        &quot;name&quot;: &quot;大大怪----------&quot;, // 名称
        &quot;name_color&quot;: 0, // 名字颜色十进制数字
        &quot;name_color_str&quot;: &quot;&quot;, //名字颜色十六进制代码
        &quot;official_info&quot;: {
          // 待观测，估计是官方相关
          &quot;desc&quot;: &quot;&quot;, // ？说明？
          &quot;role&quot;: 0, // ？角色？
          &quot;title&quot;: &quot;&quot;, // ？官方title？
          &quot;type&quot;: -1 // ？类型？
        },
        &quot;origin_info&quot;: {
          // 待观测，目前看到的都是用户个人信息
          &quot;face&quot;: &quot;https://i1.hdslb.com/bfs/face/6c3ff0055a77286e2d0cdf7a27413fb207906c26.jpg&quot;, // 头像
          &quot;name&quot;: &quot;大大怪----------&quot; // 名称
        },
        &quot;risk_ctrl_info&quot;: {
          // 待观测，目前看到的都是用户个人信息
          &quot;face&quot;: &quot;https://i1.hdslb.com/bfs/face/6c3ff0055a77286e2d0cdf7a27413fb207906c26.jpg&quot;, // 头像
          &quot;name&quot;: &quot;大大怪----------&quot; // 名称
        }
      },
      &quot;guard&quot;: {
        // 大航海信息
        &quot;level&quot;: 3, // 大航海类型，0=普通用户，1=总督，2=提督，3=舰长
        &quot;expired_str&quot;: &quot;2024-11-17 23:59:59&quot; // 到期日期
      },
      &quot;guard_leader&quot;: null, // 待观测
      &quot;medal&quot;: {
        // 牌子信息
        &quot;color&quot;: 398668, // 牌子颜色（十进制数据，需要自己转十六进制颜色代码）
        &quot;color_border&quot;: 6809855, // 牌子边框颜色（十进制数据，需要自己转十六进制颜色代码）
        &quot;color_end&quot;: 6850801, // 牌子右侧颜色（渐变）（十进制数据，需要自己转十六进制颜色代码）
        &quot;color_start&quot;: 398668, // 牌子左侧颜色（渐变）（十进制数据，需要自己转十六进制颜色代码）
        &quot;guard_icon&quot;: &quot;https://i0.hdslb.com/bfs/live/143f5ec3003b4080d1b5f817a9efdca46d631945.png&quot;, // 大航海图标
        &quot;guard_level&quot;: 3, // 大航海类型，0=普通用户，1=总督，2=提督，3=舰长
        &quot;honor_icon&quot;: &quot;&quot;, // 待观测，目前无数据
        &quot;id&quot;: 0, // 待观测，目前都是0
        &quot;is_light&quot;: 1, // 牌子是否点亮，1=是，0=否
        &quot;level&quot;: 27, // 牌子等级
        &quot;name&quot;: &quot;泠妻&quot;, // 牌子名称
        &quot;ruid&quot;: 3494365156608185, // 牌子所属主播uid
        &quot;score&quot;: 50093206, // 某种评分？待观测
        &quot;typ&quot;: 0, // 待观测，目前都是0
        &quot;user_receive_count&quot;: 0, // 待观测，目前都是0
        &quot;v2_medal_color_border&quot;: &quot;#58A1F8FF&quot;, // v2版本牌子边框颜色
        &quot;v2_medal_color_end&quot;: &quot;#4775EFCC&quot;, // v2版本牌子右侧颜色（渐变）
        &quot;v2_medal_color_level&quot;: &quot;#000B7099&quot;, // v2版本牌子等级颜色
        &quot;v2_medal_color_start&quot;: &quot;#4775EFCC&quot;, // v2版本牌子左侧颜色（渐变）
        &quot;v2_medal_color_text&quot;: &quot;#FFFFFFFF&quot; // v2版本牌子内容颜色
      },
      &quot;title&quot;: null, // 待观测
      &quot;uhead_frame&quot;: {
        // 头像框信息
        &quot;frame_img&quot;: &quot;https://i0.hdslb.com/bfs/live/80f732943cc3367029df65e267960d56736a82ee.png&quot;, // 头像框URL
        &quot;id&quot;: 1775 // 头像框ID
      },
      &quot;uid&quot;: 481471034, // 用户uid
      &quot;wealth&quot;: {
        // 待观测
        &quot;dm_icon_key&quot;: &quot;&quot;, // 待观测
        &quot;level&quot;: 24 // 待观测
      }
    },
    &quot;uname&quot;: &quot;欧豆豆的派大星&quot;, // 用户名
    &quot;uname_color&quot;: &quot;&quot; // 用户名颜色
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;PK 即将开始:PK_BATTLE_PRE_NEW&lt;/h3&gt;
&lt;p&gt;| 字段                 | 字段类型 | 字段说明                     |
| :------------------- | :------- | :--------------------------- |
| cmd                  | string   | 固定命令                     |
| data                 | array    | 数据信息                     |
| data.battle_sub_type | int      | 待观测                       |
| data.battle_type     | int      | pk 类型                      |
| data.end_win_task    | -        | 待观测                       |
| data.face            | string   | pk 对象的头像                |
| data.is_followed     | int      | 待观测                       |
| data.match_type      | int      | 待观测                       |
| data.pk_votes_name   | string   | pk 得分名称                  |
| data.pre_timer       | int      | 前置定时器（10 秒后开始 pk） |
| data.room_id         | int      | pk 对象的房间号              |
| data.season_id       | int      | 待观测                       |
| data.uid             | int      | pk 对象的 uid                |
| data.uname           | string   | pk 对象的名称                |
| pk_id                | int      | PKID                         |
| pk_status            | int      | pk 状态，代表值待观察        |
| status_msg           | string   | pk 状态文字说明              |
| template_id          | string   | 待观测                       |
| timestamp            | int      | 时间                         |&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;cmd&quot;: &quot;PK_BATTLE_PRE_NEW&quot;, // 固定命令
  &quot;pk_id&quot;: 360499657, // PKID
  &quot;pk_status&quot;: 101, // pk状态，代表值待观察
  &quot;status_msg&quot;: &quot;&quot;, // pk状态文字说明
  &quot;timestamp&quot;: 1730295557, // 时间
  &quot;data&quot;: {
    // 数据信息
    &quot;is_followed&quot;: 0, // 待观测
    &quot;uname&quot;: &quot;西红柿教主大人&quot;, // pk对象的名称
    &quot;face&quot;: &quot;https://i2.hdslb.com/bfs/face/638b5ce6a4ecc689ea76a94ba7119fa8ffb53b41.jpg&quot;, // pk对象的头像
    &quot;uid&quot;: 471024854, // pk对象的uid
    &quot;room_id&quot;: 31948297, // pk对象的房间号
    &quot;season_id&quot;: 78, // 待观测
    &quot;pre_timer&quot;: 10, // 前置定时器（10秒后开始pk）
    &quot;pk_votes_name&quot;: &quot;PK值&quot;, // pk得分名称
    &quot;end_win_task&quot;: null, // 待观测
    &quot;battle_type&quot;: 2, // pk类型
    &quot;match_type&quot;: 1, // 待观测
    &quot;battle_sub_type&quot;: 7 // 待观测
  },
  &quot;template_id&quot;: &quot;multi_conn_grid&quot; // 待观测
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;有用的命令&lt;/h2&gt;
&lt;p&gt;几个比较实用的状态，持续更新中...&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;赠送礼物&lt;/h3&gt;
&lt;hr&gt;
&lt;h4&gt;盲盒亏损&lt;/h4&gt;
&lt;p&gt;通过判断 &lt;strong&gt;data.blind_gift&lt;/strong&gt; 的数据来取得，当 &lt;strong&gt;data.blind_gift&lt;/strong&gt; 存在数据时，则可判断礼物是盲盒礼物&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;data.blind_gift.original_gift_price&lt;/strong&gt;：盲盒的价格（电池*100）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;data.blind_gift.gift_tip_price&lt;/strong&gt;：爆出礼物的价格（电池*100）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;data.blind_gift.original_gift_id&lt;/strong&gt;：盲盒的 ID&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;data.blind_gift.original_gift_name&lt;/strong&gt;：盲盒的名称&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;通过盲盒 ID 或名称分类可以记录没种盲盒的盈亏&lt;/p&gt;
&lt;p&gt;更进一步通过 &lt;strong&gt;data.uid&lt;/strong&gt; 字段分类可记录每个用户&amp;#x26;每种盲盒的盈亏&lt;/p&gt;
&lt;hr&gt;
&lt;h4&gt;感谢礼物&lt;/h4&gt;
&lt;p&gt;接收到数据即为有人赠送了礼物&lt;/p&gt;
&lt;p&gt;如果有需要，可以通过判断 &lt;strong&gt;data.coin_type == gold&lt;/strong&gt; 或者 &lt;strong&gt;data.giftType != 5&lt;/strong&gt; 来过滤免费礼物&lt;/p&gt;
&lt;p&gt;礼物总价可以通过 &lt;strong&gt;data.price * data.num&lt;/strong&gt; 获得&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;B 站发言有频率限制，建议发送消息的逻辑单做一条队列，每 3 秒执行一条发送&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;进阶：建议将 &lt;strong&gt;感谢礼物事件&lt;/strong&gt; 封装成队列，每当有需要感谢的礼物出现时加入队列，同一个用户赠送数据合并，当 &lt;strong&gt;消息发送队列&lt;/strong&gt; 无更高优先级任务时取一条进行感谢，可以避免同一用户短时间大量赠送带来的问题&lt;/p&gt;
&lt;/blockquote&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;data.uid&lt;/strong&gt;：送礼人 uid&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;data.uname&lt;/strong&gt;：送礼人名称&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;data.coin_type&lt;/strong&gt;：礼物是否是免费礼物&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;data.giftId&lt;/strong&gt;：礼物 ID&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;data.giftName&lt;/strong&gt;：礼物名称&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;data.price&lt;/strong&gt;：礼物单价&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;data.num&lt;/strong&gt;：赠送数量&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;receiver_uinfo.uid&lt;/strong&gt;：收礼人 uid（主播的 id）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;sender_uinfo.medal.ruid&lt;/strong&gt;：用户携带的牌子归属主播的 uid（可以根据 &lt;code&gt;receiver_uinfo.uid == sender_uinfo.medal.ruid&lt;/code&gt; 来确认送礼人携带的当前主播的牌子）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;sender_uinfo.medal.guard_level&lt;/strong&gt;：大航海类型，0=普通用户，1=总督，2=提督，3=舰长（所携带的牌子，有可能不是主播的，需要自行判断）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;sender_uinfo.medal.level&lt;/strong&gt;：牌子等级（所携带的牌子，有可能不是主播的，需要自行判断）&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;理论上可以连击信息来判断在连击结束之后所有的礼物一起感谢，但因工作比较忙还没有时间去观测对应的数据，后续补全&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3&gt;弹幕信息&lt;/h3&gt;
&lt;h4&gt;自动回复&lt;/h4&gt;
&lt;p&gt;通过检查弹幕信息，可以为关键词匹配自动回复&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;建议留足配置空间，以便拓展，例如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;多关键词配置，命中一个就触发或全部命中才触发&lt;/li&gt;
&lt;li&gt;黑名单配置，某些用户的发言不触发自动回复&lt;/li&gt;
&lt;li&gt;多禁词配置，与关键词相同，命中一个就不触发自动回复或全部命中就不触发自动回复&lt;/li&gt;
&lt;li&gt;多回复项，可以配置多个回复项在触发自动回复时随机触发一条&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;info.1&lt;/strong&gt;：文本信息&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;info.2.0&lt;/strong&gt;：发送人 uid&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;info.2.1&lt;/strong&gt;：发送人用户名&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;info.3.3&lt;/strong&gt;：牌子所属房间号（可以通过该字段与链接的房间号匹配判断携带的当前主播的牌子）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;info.3.10&lt;/strong&gt;：大航海类型，0=普通用户，1=总督，2=提督，3=舰长（所携带的牌子，有可能不是主播的，需要自行判断）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;info.3.0&lt;/strong&gt;：牌子等级（所携带的牌子，有可能不是主播的，需要自行判断）&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;blockquote&gt;
&lt;p&gt;弹幕信息事件可以做的事情非常多，类似抽奖一类的与弹幕互动的功能均可实现，拓展性极强&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;开通舰长&lt;/h3&gt;
&lt;h4&gt;感谢上舰&lt;/h4&gt;
&lt;blockquote&gt;
&lt;p&gt;当前开通大航海已经不会触发 &lt;strong&gt;SEND_GIFT&lt;/strong&gt; 事件，需要单独处理&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3&gt;舰长进入直播间&lt;/h3&gt;
&lt;hr&gt;
&lt;h4&gt;进房欢迎&lt;/h4&gt;
&lt;p&gt;对来到直播间的用户进行欢迎&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;建议在进房量少时再启用，或根据牌子进行过滤，否则大量进房信息积压会导致机器人欢迎不及时&lt;/p&gt;
&lt;/blockquote&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;data.uinfo.medal.ruid&lt;/strong&gt;：用户携带的牌子归属主播的 uid（目前看来可以根据 &lt;code&gt;data.target_id == data.uinfo.medal.ruid&lt;/code&gt; 来判断用户携带的是否是主播自己的牌子）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;data.uinfo.medal.guard_level&lt;/strong&gt;：大航海类型，0=普通用户，1=总督，2=提督，3=舰长（所携带的牌子，有可能不是主播的，需要自行判断）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;data.uinfo.medal.level&lt;/strong&gt;：牌子等级（所携带的牌子，有可能不是主播的，需要自行判断）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;data.uinfo.base.name&lt;/strong&gt;：用户名&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;data.uinfo.uid&lt;/strong&gt;：用户 ID&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3&gt;直播间互动&lt;/h3&gt;
&lt;hr&gt;
&lt;h4&gt;进房欢迎 &amp;#x26; 感谢关注 &amp;#x26; 感谢分享&lt;/h4&gt;
&lt;p&gt;三个消息都由一个推送进行，通过 &lt;strong&gt;data.msg_type&lt;/strong&gt; 进行区分&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;建议在进房量少时再启用，或根据牌子进行过滤，否则大量进房信息积压会导致机器人欢迎不及时&lt;/p&gt;
&lt;/blockquote&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;data.fans_medal.anchor_roomid&lt;/strong&gt;：牌子主播房间号（目前看来可以根据 &lt;code&gt;data.fans_medal.anchor_roomid == 直播间房间号&lt;/code&gt; 来判断用户携带的是否是主播自己的牌子）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;data.fans_medal.guard_level&lt;/strong&gt;：大航海类型，0=普通用户，1=总督，2=提督，3=舰长（所携带的牌子，有可能不是主播的，需要自行判断）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;data.uid&lt;/strong&gt;：用户 uid&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;data.uname&lt;/strong&gt;：用户名称&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;data.msg_type&lt;/strong&gt;：消息类型，1=进入直播间，2=关注，3=分享直播间&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3&gt;PK 即将开始&lt;/h3&gt;
&lt;hr&gt;
&lt;h4&gt;PK 播报&lt;/h4&gt;
&lt;p&gt;可以在 PK 开始之前（前 10 秒）获取到 PK 方的信息，通过该信息可查询对方直播间的信息并进行播报&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;data.uid&lt;/strong&gt;：主播 uid&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;data.uname&lt;/strong&gt;：主播名称&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;data.room_id&lt;/strong&gt;：直播间房间号&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;</content:encoded><h:img src="/_astro/bilibili.p76c9pFG.jpg"/><enclosure url="/_astro/bilibili.p76c9pFG.jpg"/></item><item><title>从PHP-FPM到常驻进程：Swoole与Workerman的优劣与对比</title><link>https://hejunjie.life/blog/ddb3efc5</link><guid isPermaLink="true">https://hejunjie.life/blog/ddb3efc5</guid><description>了解 Swoole 和 Workerman 常驻进程 PHP 框架的特点及优劣，分析它们与传统 PHP-FPM 模式的对比。本文详细介绍了这两种框架在高并发、异步 IO 和实时应用中的优势，帮助开发者选择合适的 PHP 技术方案</description><pubDate>Wed, 23 Oct 2024 10:59:36 GMT</pubDate><content:encoded>&lt;p&gt;在 PHP 开发中，传统的 PHP-FPM 模式一直是主流，但随着高并发和实时通信场景的增多，常驻进程模式的框架如 Swoole 和 Workerman 逐渐受到了关注。这些框架通过异步非阻塞 IO 和常驻进程模型，能够显著提升应用的并发处理能力。那么，这些框架与传统的 PHP-FPM 模式相比，有哪些优劣？本文将从架构特点、性能表现和使用场景等方面对 Swoole 和 Workerman 进行详细比较。&lt;/p&gt;
&lt;h1&gt;一、PHP-FPM：同步阻塞模型的代表&lt;/h1&gt;
&lt;p&gt;PHP-FPM 是目前最常见的 PHP 处理模式，适用于大多数 Web 应用，但它的同步阻塞模型有明显的性能瓶颈。请求处理流程大致如下：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;客户端发起请求，通过 Web 服务器（如 Nginx、Apache）传递给 PHP-FPM。&lt;/li&gt;
&lt;li&gt;PHP-FPM 创建新进程处理请求，每个进程独立解析、执行脚本。&lt;/li&gt;
&lt;li&gt;同步阻塞执行：遇到 IO 操作时（如数据库查询、文件读写），整个进程会等待操作完成后继续执行。&lt;/li&gt;
&lt;li&gt;请求结束，PHP-FPM 销毁进程或返回进程池，响应结果通过 Web 服务器传递给客户端。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;PHP-FPM 每次请求都需要启动新的 PHP 进程，造成频繁的进程创建和销毁开销，尤其在高并发场景下性能受限明显。&lt;/p&gt;
&lt;h1&gt;二、常驻进程的优势：Swoole 和 Workerman&lt;/h1&gt;
&lt;p&gt;相比 PHP-FPM，Swoole 和 Workerman 采用了常驻进程模式，避免了频繁的进程启动和销毁。它们的主要特点包括：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;常驻进程&lt;/strong&gt;：服务器启动后，进程常驻内存，所有请求都在同一进程内处理，大幅减少了 PHP-FPM 中频繁创建进程的开销。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;异步非阻塞 IO&lt;/strong&gt;：两者都通过异步 IO 处理任务，不会因为慢速操作（如数据库查询、文件操作）而阻塞整个进程，提升了并发处理能力。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;高并发支持&lt;/strong&gt;：相比 PHP-FPM 的同步阻塞模型，Swoole 和 Workerman 能够处理成千上万的并发请求，特别适合需要处理长连接和实时通信的应用场景。&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;三、Swoole 与 Workerman 的对比&lt;/h1&gt;
&lt;p&gt;尽管 Swoole 和 Workerman 都是常驻进程模式，它们在设计和实现上有不少区别。&lt;/p&gt;
&lt;h2&gt;Swoole：高性能与复杂性&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;底层实现&lt;/strong&gt;：Swoole 使用 C 语言编写，性能极为强大，尤其在高并发场景下，Swoole 的协程机制能够充分利用 CPU 资源，避免阻塞。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;协程支持&lt;/strong&gt;：Swoole 提供了协程，允许开发者在编写同步代码的同时享受异步的性能。这种方式能够极大减少异步编程的复杂性，尤其适用于高并发任务。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;学习曲线&lt;/strong&gt;：由于底层实现复杂，且文档相对晦涩，对于没有 C 语言背景的开发者来说，修改 Swoole 的底层代码不太友好，但它的团队响应及时。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Workerman：简洁与易用性&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;纯 PHP 实现&lt;/strong&gt;：Workerman 完全用 PHP 实现，开发者无需学习 C 语言，修改和扩展也相对简单，更适合快速开发和维护。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;性能适中&lt;/strong&gt;：虽然 Workerman 的性能上限不如 Swoole，但对于大部分中小型项目，Workerman 的性能已经足够，且由于没有复杂的底层优化，调试和维护更加容易。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;使用场景&lt;/strong&gt;：适用于中等并发场景，如 WebSocket、实时推送等应用，尤其适合中小型项目开发。&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;四、Swoole 与 Workerman 相比 PHP-FPM 的优势&lt;/h1&gt;
&lt;h2&gt;并发处理能力提升&lt;/h2&gt;
&lt;p&gt;在传统的 PHP-FPM 中，每个请求都需要启动一个独立的进程，这在高并发场景下会造成大量的资源开销。而 Swoole 和 Workerman 的常驻进程模式允许多个请求在同一进程中处理，显著提升了并发处理能力。Swoole 的协程机制进一步增强了处理大量并发连接时的性能。&lt;/p&gt;
&lt;h2&gt;异步非阻塞 IO&lt;/h2&gt;
&lt;p&gt;Swoole 和 Workerman 都支持异步非阻塞 IO，当遇到慢速的 IO 操作（如数据库查询、文件读写）时，不会阻塞整个进程，这意味着即使在高负载下，服务器也能保持高效响应。而在 PHP-FPM 中，慢速 IO 会阻塞整个进程，导致系统吞吐量大幅下降。&lt;/p&gt;
&lt;h2&gt;长连接与实时应用支持&lt;/h2&gt;
&lt;p&gt;常驻进程模式的框架特别适合处理长连接，比如 WebSocket 和聊天服务。PHP-FPM 因为每个请求都需要重新创建进程，无法很好地支持这些场景。而 Swoole 和 Workerman 都能够处理持久的长连接，适用于实时通信和推送服务。&lt;/p&gt;
&lt;h1&gt;五、总结：如何选择适合的框架？&lt;/h1&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;性能与复杂度的平衡： 如果你的项目对性能有极高要求，且团队具备一定的 C 语言能力，可以选择 Swoole，尤其在需要处理大量并发或复杂网络协议时，Swoole 的协程和底层优化会带来极大优势。
如果你的项目规模较小或中等，Workerman 是更为简洁易用的选择，纯 PHP 实现让它更加容易维护，同时也能提供不错的并发处理能力。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;项目类型： 如果你的应用是传统的 Web 应用（如电商、博客等），PHP-FPM 已经足够处理。如果你需要处理长连接、实时推送或高并发服务，常驻进程的 Swoole 和 Workerman 是更合适的选择。
最终，选择 Swoole 还是 Workerman，取决于项目的复杂性和团队的技术背景。如果你的应用需要极致的性能优化，可以考虑 Swoole；如果追求简单高效的开发体验，Workerman 是不错的选择。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;</content:encoded><h:img src="/_astro/php.DoApYH8-.png"/><enclosure url="/_astro/php.DoApYH8-.png"/></item><item><title>Progressive Web App (PWA)：现代网页应用的进化</title><link>https://hejunjie.life/blog/83b5e14f</link><guid isPermaLink="true">https://hejunjie.life/blog/83b5e14f</guid><description>了解什么是 Progressive Web App (PWA)，它如何结合网页与原生应用的优势，为用户提供离线访问、推送通知、全屏显示等原生应用体验。本文详细讲解了 PWA 的特点与实现步骤，帮助开发者轻松将网页转换为渐进式网页应用</description><pubDate>Mon, 21 Oct 2024 07:30:26 GMT</pubDate><content:encoded>&lt;p&gt;随着网络技术的进步，网页应用变得越来越强大。今天，我们可以通过一种被称为 &lt;strong&gt;Progressive Web App (PWA)&lt;/strong&gt; 的技术，将网页应用提升到接近原生应用的体验。那么，什么是 PWA？它能为我们带来什么？又该如何实现它呢？本文将为你一一解答。&lt;/p&gt;
&lt;h1&gt;什么是 Progressive Web App (PWA)？&lt;/h1&gt;
&lt;p&gt;&lt;strong&gt;Progressive Web App&lt;/strong&gt;，即渐进式网页应用，是一种结合了网页和原生应用优势的技术，旨在为用户提供类似原生应用的用户体验。传统网页应用虽然跨平台，但在体验上往往不如原生应用，而 PWA 通过现代 Web 技术弥补了这一差距，允许开发者构建能够离线访问、支持推送通知、并可以安装到主屏幕上的应用。&lt;/p&gt;
&lt;p&gt;PWA 不仅保持了网页应用无需下载和安装的优势，还让用户在不同设备上获得流畅的一致体验，无论是桌面浏览器还是移动设备，PWA 都能自动适配。&lt;/p&gt;
&lt;h1&gt;Progressive Web App 能做什么？&lt;/h1&gt;
&lt;p&gt;PWA 在用户体验方面实现了多种原生应用的功能，包括：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;离线访问：通过缓存技术，PWA 能够在无网络连接时继续工作。&lt;/li&gt;
&lt;li&gt;推送通知：支持实时推送通知，让用户保持与应用的互动。&lt;/li&gt;
&lt;li&gt;安装到主屏幕：用户可以将 PWA 添加到主屏幕，无需访问浏览器地址栏。&lt;/li&gt;
&lt;li&gt;全屏运行：PWA 可以像原生应用一样在全屏模式下运行，隐藏浏览器 UI。&lt;/li&gt;
&lt;li&gt;自动更新：应用可以自动在后台获取最新版本，而无需用户手动更新。&lt;/li&gt;
&lt;li&gt;性能优化：PWA 通过响应式设计和快速加载的技术，提供流畅的用户体验。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这些特性不仅为用户带来了便利，也大大降低了开发者的跨平台开发成本。通过一次开发，PWA 能够在不同操作系统上提供一致的体验。&lt;/p&gt;
&lt;h1&gt;Progressive Web App 的特点&lt;/h1&gt;
&lt;p&gt;PWA 是基于一系列现代 Web 技术的集合，它具备以下几个显著特点：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;渐进式增强：PWA 依赖现代浏览器的能力，但在旧版本浏览器上依然可以正常工作。&lt;/li&gt;
&lt;li&gt;响应式设计：PWA 能自动适配各种屏幕大小，从手机到桌面，用户都能获得流畅的体验。&lt;/li&gt;
&lt;li&gt;离线能力：通过 Service Worker 技术，PWA 可以缓存静态资源，在网络不稳定时也能继续使用。&lt;/li&gt;
&lt;li&gt;安全性：PWA 必须通过 HTTPS 提供服务，确保数据传输安全。&lt;/li&gt;
&lt;li&gt;安装性：用户可以将 PWA 添加到主屏幕，体验上与原生应用无异。&lt;/li&gt;
&lt;li&gt;轻量级：PWA 不需要占用大量存储空间，启动速度快，同时也没有应用商店的审查流程。&lt;/li&gt;
&lt;/ol&gt;
&lt;h1&gt;如何实现 Progressive Web App？&lt;/h1&gt;
&lt;p&gt;实现一个 PWA 并不复杂，只需要几步即可将现有的网页应用转变为 PWA。&lt;/p&gt;
&lt;h2&gt;创建 Web App Manifest&lt;/h2&gt;
&lt;p&gt;首先，需要创建一个 manifest 文件，这是一个 JSON 文件，用来定义应用的图标、名称、启动 URL 以及显示方式等。例如：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;name&quot;: &quot;PWA示例&quot;,
  &quot;short_name&quot;: &quot;PWA&quot;,
  &quot;start_url&quot;: &quot;/index.html&quot;,
  &quot;display&quot;: &quot;standalone&quot;,
  &quot;background_color&quot;: &quot;#ffffff&quot;,
  &quot;theme_color&quot;: &quot;#000000&quot;,
  &quot;icons&quot;: [
    {
      &quot;src&quot;: &quot;/images/icon-192x192.png&quot;,
      &quot;sizes&quot;: &quot;192x192&quot;,
      &quot;type&quot;: &quot;image/png&quot;
    },
    {
      &quot;src&quot;: &quot;/images/icon-512x512.png&quot;,
      &quot;sizes&quot;: &quot;512x512&quot;,
      &quot;type&quot;: &quot;image/png&quot;
    }
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;将此文件命名为 &lt;code&gt;manifest.json&lt;/code&gt;，并在你的 HTML 页面中引入：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;#x3C;link rel=&quot;manifest&quot; href=&quot;/manifest.json&quot; /&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;注册 Service Worker&lt;/h2&gt;
&lt;p&gt;Service Worker 是 PWA 的核心，它是一种能够在后台运行的脚本，负责离线缓存和网络请求拦截。你可以创建一个简单的 &lt;code&gt;service-worker.js&lt;/code&gt; 文件，用来缓存应用的静态资源：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;self.addEventListener(&apos;install&apos;, (event) =&gt; {
  event.waitUntil(
    caches.open(&apos;pwa-cache&apos;).then((cache) =&gt; {
      return cache.addAll([&apos;/&apos;, &apos;/index.html&apos;, &apos;/styles.css&apos;, &apos;/script.js&apos;])
    })
  )
})

self.addEventListener(&apos;fetch&apos;, (event) =&gt; {
  event.respondWith(
    caches.match(event.request).then((response) =&gt; {
      return response || fetch(event.request)
    })
  )
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在你的应用中注册这个 Service Worker：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;if (&apos;serviceWorker&apos; in navigator) {
  navigator.serviceWorker
    .register(&apos;/service-worker.js&apos;)
    .then((registration) =&gt; {
      console.log(&apos;Service Worker 注册成功，作用域:&apos;, registration.scope)
    })
    .catch((error) =&gt; {
      console.log(&apos;Service Worker 注册失败:&apos;, error)
    })
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;确保通过 HTTPS 提供服务&lt;/h2&gt;
&lt;p&gt;为了保证安全性，PWA 必须通过 HTTPS 提供服务。这可以通过配置 SSL 证书来实现&lt;/p&gt;
&lt;h2&gt;优化用户体验&lt;/h2&gt;
&lt;p&gt;可以通过 meta 标签优化 PWA 在 iOS 设备上的显示效果，例如：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;#x3C;meta name=&quot;apple-mobile-web-app-capable&quot; content=&quot;yes&quot; /&gt;
&amp;#x3C;meta name=&quot;apple-mobile-web-app-status-bar-style&quot; content=&quot;black&quot; /&gt;
&amp;#x3C;link rel=&quot;apple-touch-icon&quot; href=&quot;/images/icon-192x192.png&quot; /&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;总结&lt;/h1&gt;
&lt;p&gt;&lt;strong&gt;Progressive Web App (PWA)&lt;/strong&gt; 让开发者能够构建跨平台、功能强大且用户体验接近原生应用的网页应用。通过使用 Web App Manifest、Service Worker 以及 HTTPS，你可以轻松将现有网页转变为 PWA，为用户提供离线支持、推送通知、快速加载等功能。&lt;/p&gt;
&lt;p&gt;PWA 的出现标志着网页应用体验的巨大飞跃。无论是希望降低开发成本的企业，还是希望提升用户体验的开发者，PWA 都是一个值得关注和实施的技术方案。&lt;/p&gt;</content:encoded><h:img src="/_astro/web.n3Pk-HlC.jpg"/><enclosure url="/_astro/web.n3Pk-HlC.jpg"/></item><item><title>定期备份数据库：基于 Shell 脚本的自动化方案</title><link>https://hejunjie.life/blog/477811e7</link><guid isPermaLink="true">https://hejunjie.life/blog/477811e7</guid><description>本篇文章分享一个简单的 Shell 脚本，用于定期备份 MySQL 数据库，并自动将备份传输到远程服务器，帮助防止数据丢失</description><pubDate>Sun, 20 Oct 2024 10:55:46 GMT</pubDate><content:encoded>&lt;p&gt;数据库备份这件事，说实话，我一直没怎么上心。平时服务器跑得好好的，谁会想着备份呢？直到某天真出问题了，才意识到自己平时有多“懒”。&lt;/p&gt;
&lt;p&gt;我相信很多人跟我一样，觉得这东西看起来麻烦，等到数据库挂了、数据丢失了，才感叹自己怎么就没提前准备好呢？&lt;/p&gt;
&lt;p&gt;有一次数据库问题搞得我手忙脚乱，最后还好有个朋友给了我个备份文件，才算是有惊无险。&lt;/p&gt;
&lt;p&gt;经历了这次以后，我决定不能再拖了，必须把备份这事儿自动化起来。所以，我写了一个简单的 Shell 脚本，每天自动帮我把数据库备份好，还自动传到远程服务器上，再也不用担心数据丢失了。&lt;/p&gt;
&lt;p&gt;今天就把这个脚本分享出来，或许能帮到和我一样平时“佛系”的朋友。脚本简单好用，拿来直接用就行，具体的实现逻辑也不复杂，如果你有其他需求，可以自行优化。&lt;/p&gt;
&lt;h2&gt;备份脚本内容&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;#!/bin/bash

# MySQL连接参数（可以使用 ~/.my.cnf 文件代替）
DB_USER=&quot;数据库账号&quot;
DB_PASS=&quot;数据库密码&quot;

# 备份目录
BACKUP_DIR=&quot;/数据库备份目录&quot;

# 日志文件路径
CURRENT_DATE=$(date +&apos;%Y%m%d%H%M%S&apos;)
LOG_FILE=&quot;/数据库备份日志目录/$CURRENT_DATE.log&quot;

# 目标服务器的 SSH 用户名和 IP 地址，如果不需要同步到其他服务器则不需要填写
# -----------------------------
REMOTE_USER=&quot;&quot; # 目标服务器账号
REMOTE_HOST=&quot;&quot; #目标服务器IP
REMOTE_DIR=&quot;&quot; # 目标服务器存储路径
# -----------------------------

# SSH 连接超时时间（单位：秒）
SSH_CONNECT_TIMEOUT=10

# 创建备份和日志目录（如果不存在）
mkdir -p &quot;$BACKUP_DIR&quot;
mkdir -p &quot;$(dirname &quot;$LOG_FILE&quot;)&quot;

# 获取所有数据库列表
DATABASES=$(mysql -e &quot;SHOW DATABASES;&quot; | grep -Ev &quot;(Database|information_schema|performance_schema|mysql)&quot;)

# 循环备份每个数据库
BACKUP_FILES=()
for DB in $DATABASES; do
    BACKUP_FILE=&quot;$BACKUP_DIR/$DB-$CURRENT_DATE.sql.gz&quot;
    echo &quot;$(date +%Y%m%d%H%M%S) 备份数据库 $DB 到文件 $BACKUP_FILE&quot; &gt;&gt; $LOG_FILE
    if ! mysqldump -u $DB_USER -p$DB_PASS --single-transaction --databases $DB | gzip &gt; $BACKUP_FILE; then
        echo &quot;$(date +%Y%m%d%H%M%S) 备份数据库 $DB 失败&quot; &gt;&gt; $LOG_FILE
        continue  # 跳过当前数据库，继续备份其他数据库
    fi
    echo &quot;$(date +%Y%m%d%H%M%S) 备份完成&quot; &gt;&gt; $LOG_FILE
    BACKUP_FILES+=($BACKUP_FILE)
done

# 创建远程备份目录
if [ -n &quot;$REMOTE_USER&quot; ] &amp;#x26;&amp;#x26; [ -n &quot;$REMOTE_HOST&quot; ] &amp;#x26;&amp;#x26; [ -n &quot;$REMOTE_DIR&quot; ]; then
    ssh -o ConnectTimeout=$SSH_CONNECT_TIMEOUT $REMOTE_USER@$REMOTE_HOST &quot;mkdir -p $REMOTE_DIR&quot;

    # 批量传输备份文件
    if [ ${#BACKUP_FILES[@]} -gt 0 ]; then
        echo &quot;$(date +%Y%m%d%H%M%S) 将备份文件发送到远程服务器...&quot; &gt;&gt; $LOG_FILE
        scp -o ConnectTimeout=$SSH_CONNECT_TIMEOUT ${BACKUP_FILES[@]} $REMOTE_USER@$REMOTE_HOST:$REMOTE_DIR
        echo &quot;$(date +%Y%m%d%H%M%S) 备份文件发送完成&quot; &gt;&gt; $LOG_FILE
    fi

    # 删除本地备份文件
    echo &quot;删除本地备份文件...&quot; &gt;&gt; $LOG_FILE
    for BACKUP_FILE in &quot;${BACKUP_FILES[@]}&quot;; do
        rm -f $BACKUP_FILE
    done
    echo &quot;本地备份文件删除完成&quot; &gt;&gt; $LOG_FILE
fi

# 完成
echo &quot;------------------------------&quot; &gt;&gt; $LOG_FILE

&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;关于同步到其他服务器&lt;/h2&gt;
&lt;p&gt;如果需要同步到远程服务器的话，为了避免每次执行脚本时都手动输入密码，可以使用 &lt;strong&gt;SSH 密钥对&lt;/strong&gt;（公钥和私钥）来实现免密登录&lt;/p&gt;
&lt;p&gt;具体步骤如下：&lt;/p&gt;
&lt;h3&gt;在数据库所在机器上生成 SSH 密钥对&lt;/h3&gt;
&lt;p&gt;首先，确保你在数据库机器上没有已有的 SSH 密钥对（如果有，跳过这一步）。在 A 机器上运行以下命令来生成一个新的 SSH 密钥对：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ssh-keygen -t rsa -b 2048
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;按照提示操作，默认保存路径是 &lt;code&gt;~/.ssh/id_rsa&lt;/code&gt;​，可以选择直接按 Enter 使用默认路径。生成密钥后，你会得到两个文件：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;id_rsa&lt;/strong&gt;：私钥文件，保存在数据库所在机器上。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;id_rsa.pub&lt;/strong&gt;：公钥文件，用于复制到需要同步的机器。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;将公钥复制到需要同步的机器&lt;/h3&gt;
&lt;p&gt;你需要将 &lt;code&gt;id_rsa.pub&lt;/code&gt;​ 的内容复制到需要同步的机器上，添加到目标用户的 &lt;code&gt;~/.ssh/authorized_keys&lt;/code&gt;​ 文件中。你可以使用以下命令来实现这一点：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ssh-copy-id -i ~/.ssh/id_rsa.pub 需要同步的机器目标用户@需要同步的机器的IP
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果你的目标用户在需要同步的机器上是 &lt;code&gt;remote_user&lt;/code&gt;​，IP 地址是 &lt;code&gt;192.168.1.100&lt;/code&gt;​，那么命令会是：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ssh-copy-id -i ~/.ssh/id_rsa.pub remote_user@192.168.1.100
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;执行后，你将被提示输入需要同步的机器的密码，输入一次后，公钥将被自动添加到需要同步的机器上的 &lt;code&gt;~/.ssh/authorized_keys&lt;/code&gt;​ 文件中。&lt;/p&gt;
&lt;h3&gt;验证免密登录&lt;/h3&gt;
&lt;p&gt;此时，你应该能够从数据库所在机器免密登录到需要同步的机器，试试这个命令：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ssh remote_user@192.168.1.100
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果成功登录且没有要求输入密码，则说明配置成功。&lt;/p&gt;
&lt;h2&gt;总结&lt;/h2&gt;
&lt;p&gt;虽然数据库备份在日常中似乎并不重要，但等到真正出问题时，备份的价值就凸显出来了。&lt;/p&gt;
&lt;p&gt;有了这个脚本后，我再也不用手动备份数据库，也不怕丢数据了。&lt;/p&gt;
&lt;p&gt;希望这份脚本能帮到大家。&lt;/p&gt;</content:encoded><h:img src="/_astro/mysql.C4E994Tu.png"/><enclosure url="/_astro/mysql.C4E994Tu.png"/></item><item><title>Laravel Eloquent 关联与 JOIN 查询：性能权衡与最佳实践</title><link>https://hejunjie.life/blog/611dce04</link><guid isPermaLink="true">https://hejunjie.life/blog/611dce04</guid><description>本文讲述了 Eloquent 模型关联的工作原理，解释延迟加载（Lazy Loading）和预加载（Eager Loading），并比较它们与 JOIN 查询的性能差异</description><pubDate>Fri, 18 Oct 2024 03:19:06 GMT</pubDate><content:encoded>&lt;p&gt;Laravel 是一个优秀的 PHP 框架，其强大的 Eloquent ORM 提供了简洁而灵活的模型关系定义。然而，在处理大规模数据时，如何权衡 Eloquent 关联与数据库 JOIN 查询的性能问题，往往是开发者关注的焦点。&lt;/p&gt;
&lt;p&gt;在这篇文章中，我们将探讨 Eloquent 模型关联的工作原理，解释延迟加载（Lazy Loading）和预加载（Eager Loading），并比较它们与 JOIN 查询的性能差异。最后，我们将讨论一些在大数据集下提升查询效率的最佳实践。&lt;/p&gt;
&lt;h1&gt;Eloquent 关联的工作原理&lt;/h1&gt;
&lt;p&gt;Laravel 提供了多种 Eloquent 关联方式，包括一对一（One-to-One）、一对多（One-to-Many）、多对多（Many-to-Many）以及多态关联（Polymorphic Relations）。这些关系可以让我们通过模型直接获取相关数据。&lt;/p&gt;
&lt;p&gt;一个常见的示例是用户模型与手机号码模型之间的一对一关联：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;class User extends Model {
    public function phone() {
        return $this-&gt;hasOne(Phone::class);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;我们可以通过 &lt;code&gt;$user-&gt;phone&lt;/code&gt; 来获取关联的手机号码。在这个过程中，Eloquent 默认使用 &lt;strong&gt;延迟加载（Lazy Loading）&lt;/strong&gt;，意味着只有在访问 &lt;code&gt;$user-&gt;phone&lt;/code&gt; 时，才会发出 SQL 查询。这种方式在小数据集下运行良好，但在处理大量数据时可能会带来性能问题。&lt;/p&gt;
&lt;h2&gt;延迟加载的性能问题&lt;/h2&gt;
&lt;p&gt;假设你有 10,000 名用户，并希望获取每个用户的手机号码：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;$users = User::all();
foreach ($users as $user) {
    echo $user-&gt;phone-&gt;number;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在这里，Laravel 会执行 1 次查询获取所有用户的数据，然后为每个用户单独执行 1 次查询获取关联的 &lt;code&gt;phone&lt;/code&gt; 数据。也就是说，总共会执行 &lt;strong&gt;10,001 次查询&lt;/strong&gt;。这种查询模式被称为 &lt;strong&gt;N+1 查询问题&lt;/strong&gt;，会对数据库性能产生很大的影响。&lt;/p&gt;
&lt;h2&gt;预加载（Eager Loading）&lt;/h2&gt;
&lt;p&gt;为了避免 N+1 查询问题，Laravel 提供了 &lt;strong&gt;预加载（Eager Loading）&lt;/strong&gt;。通过 &lt;code&gt;with&lt;/code&gt; 方法，我们可以一次性将所有关联的数据一起查询出来：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;$users = User::with(&apos;phone&apos;)-&gt;get();
foreach ($users as $user) {
    echo $user-&gt;phone-&gt;number;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在这个例子中，Laravel 会执行两次查询：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;一次查询用户表，获取所有用户；&lt;/li&gt;
&lt;li&gt;一次查询手机号码表，获取所有用户的手机号码。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;即使有 10,000 个用户，也只会执行 2 次查询，大大减少了查询次数。&lt;/p&gt;
&lt;h1&gt;Eloquent 关联与 JOIN 查询的对比&lt;/h1&gt;
&lt;p&gt;有时候，你可能会考虑直接使用 SQL 的 &lt;code&gt;JOIN&lt;/code&gt; 语句将数据一次性查询出来：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;$users = User::join(&apos;phones&apos;, &apos;users.id&apos;, &apos;=&apos;, &apos;phones.user_id&apos;)
    -&gt;select(&apos;users.*&apos;, &apos;phones.number&apos;)
    -&gt;get();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这种情况下，数据库会执行一个 &lt;code&gt;INNER JOIN&lt;/code&gt; 操作，将 &lt;code&gt;User&lt;/code&gt; 和 &lt;code&gt;Phone&lt;/code&gt; 的数据一次性查询出来。在某些场景下，使用 &lt;code&gt;JOIN&lt;/code&gt; 查询会更高效，特别是当你只需要获取部分关联数据而非整个关联模型时。&lt;/p&gt;
&lt;h2&gt;性能对比&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Eloquent 预加载&lt;/strong&gt;：适用于需要访问完整模型数据的场景。它避免了 N+1 查询问题，并保持了 Eloquent 模型的简洁性和可读性。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;JOIN 查询&lt;/strong&gt;：适用于你只需要某些特定字段而不需要加载整个关联模型的情况。&lt;code&gt;JOIN&lt;/code&gt; 操作在数据库层面完成，性能上可能更优，尤其是在进行过滤、排序或聚合操作时。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;如何选择？&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Eloquent 预加载&lt;/strong&gt;： 如果你需要使用 Eloquent 提供的所有功能，并且关联模型的数据较复杂，那么 &lt;code&gt;with&lt;/code&gt; 预加载是一个很好的选择，它在避免 N+1 查询问题的同时保留了 ORM 的灵活性。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;JOIN 查询&lt;/strong&gt;： 当你需要最大化性能，且只需要关联表的部分字段时，直接使用 &lt;code&gt;JOIN&lt;/code&gt; 查询会更高效。&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;性能优化建议&lt;/h1&gt;
&lt;p&gt;在处理大数据集时，除了选择合适的查询方式外，还有一些其他的优化建议可以帮助你提升性能：&lt;/p&gt;
&lt;h2&gt;使用批量处理 (Chunk)&lt;/h2&gt;
&lt;p&gt;如果你需要处理大量数据，Laravel 提供了 chunk 方法，可以将大数据集分批处理，避免内存占用过高：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;User::with(&apos;phone&apos;)-&gt;chunk(1000, function ($users) {
    foreach ($users as $user) {
        echo $user-&gt;phone-&gt;number;
    }
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;通过将查询结果分批处理，你可以有效减少内存的使用，特别是在处理上万条记录时。&lt;/p&gt;
&lt;h2&gt;添加数据库索引&lt;/h2&gt;
&lt;p&gt;确保为外键字段添加索引是提升查询性能的关键。例如，在 &lt;code&gt;Phone&lt;/code&gt; 表的 &lt;code&gt;user_id&lt;/code&gt; 字段上添加索引，可以加速关联查询，特别是当数据量较大时。&lt;/p&gt;
&lt;h2&gt;考虑缓存&lt;/h2&gt;
&lt;p&gt;对于频繁查询的数据，可以考虑使用 Laravel 的缓存功能来减少数据库查询次数。通过缓存关联数据，可以避免反复的数据库查询，从而提高性能。&lt;/p&gt;
&lt;h1&gt;结论&lt;/h1&gt;
&lt;p&gt;在 Laravel 中，Eloquent 关联为我们提供了简洁的 ORM 操作方式，但在处理大数据集时，性能问题不容忽视。为了解决这些问题，我们可以选择使用 &lt;code&gt;Eloquent 的预加载 (with)&lt;/code&gt; 或直接使用数据库的 &lt;code&gt;JOIN&lt;/code&gt; 查询，具体取决于数据量和查询需求。&lt;/p&gt;
&lt;p&gt;通过正确选择查询方式、使用批量处理、优化数据库索引以及引入缓存等技术手段，我们可以在享受 Eloquent 便利性的同时确保应用的性能表现。&lt;/p&gt;</content:encoded><h:img src="/_astro/php.DoApYH8-.png"/><enclosure url="/_astro/php.DoApYH8-.png"/></item><item><title>从零开始创建属于自己的 Composer 库</title><link>https://hejunjie.life/blog/72f5b414</link><guid isPermaLink="true">https://hejunjie.life/blog/72f5b414</guid><description>在本文中，我们探讨了如何从零开始创建自己的 Composer 库</description><pubDate>Thu, 17 Oct 2024 12:24:20 GMT</pubDate><content:encoded>&lt;p&gt;Composer 是 PHP 领域最流行的依赖管理工具，它使得管理项目依赖变得轻松简单。然而，除了使用现有的包，我们也可以创建和发布属于自己的 Composer 包。
在这篇文章中，我将带你一步一步完成从零开始创建并发布一个自己的 Composer 包的流程。&lt;/p&gt;
&lt;h1&gt;创建项目&lt;/h1&gt;
&lt;p&gt;在你的工作目录下创建一个新的文件夹作为你的包：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;mkdir project
cd project
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;初始化 Composer&lt;/h1&gt;
&lt;p&gt;在项目目录中运行以下命令以生成 composer.json 文件：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;composer init
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;确保您已经安装了 Composer，如果尚未安装，可以通过 Composer 的 &lt;a href=&quot;https://getcomposer.org&quot;&gt;官方网站&lt;/a&gt; 获取详细的安装说明。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;composer init 执行后会需要填写一些内容，皆指帮助您生成 composer.json 文件，所以不需要过于在意，后期也可以直接修改 composer.json 文件，大概内容如下：&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;| 问题                                                                       | 说明                                                                                | 建议                                                                                                                                              |
| :------------------------------------------------------------------------- | :---------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------ |
| Package name (vendor/package)                                              | composer 包名                                                                       | 个人&amp;#x26;组织名称/包名                                                                                                                                |
| Description                                                                | 对你的包做一个简短的描述                                                            | 控制在一两句话以内                                                                                                                                |
| Author                                                                     | 作者                                                                                | 名称 &amp;#x3C;邮箱地址&gt;                                                                                                                                   |
| Minimum Stability                                                          | 项目的最小稳定性                                                                    | beta、alpha 或 dev，但大部分项目推荐 stable                                                                                                       |
| License                                                                    | 项目使用什么许可证                                                                  | MIT、GPL 等。如果不确定，可以选择 MIT，因为它简单且宽松                                                                                           |
| Define your dependencies                                                   | 是否希望手动定义项目的依赖项                                                        | 如果你知道自己要安装的依赖库，可以选择 yes，然后输入依赖包的名称和版本号。如果暂时不确定，可以选择 no，之后可以随时通过 composer require 添加依赖 |
| Would you like to define your dev dependencies (require-dev) interactively | 是否希望定义开发依赖                                                                | 仅在开发过程中使用的依赖项，比如测试工具（如 PHPUnit）。如果你需要测试或调试工具，可以在此处添加                                                  |
| Add PSR-4 autoload mapping? Maps namespace to subdirectory                 | 是否希望定义自动加载的 PSR-4 映射                                                   | 你可以设置自己的命名空间和对应的文件夹                                                                                                            |
| Summary of settings                                                        | 会列出你填写的信息摘要供你确认。检查所有信息是否正确，并确认生成 composer.json 文件 | yes                                                                                                                                               |&lt;/p&gt;
&lt;h1&gt;提交到 GitHub 或其他版本控制系统&lt;/h1&gt;
&lt;p&gt;将你的项目提交到 GitHub 或其他版本控制系统。确保你的 composer.json 文件在根目录下。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;建议上传到 GitHub 中，关于无法访问的问题详见 &lt;a href=&quot;/posts/e8c50aef.html&quot;&gt;如何实现科学上网&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1&gt;发布到 Packagist&lt;/h1&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;在 &lt;a href=&quot;https://packagist.org&quot;&gt;Packagist&lt;/a&gt; 上注册一个帐户。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;登录后，点击右上角的 “Submit” 按钮后提交自己的 Git 链接即可发布&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;后续建议&lt;/h1&gt;
&lt;h2&gt;发布版本&lt;/h2&gt;
&lt;h3&gt;为什么要在 GitHub 上发布版本？&lt;/h3&gt;
&lt;p&gt;为了管理每次更新的内容，建议在 GitHub 中发布版本，这样做有以下几种好处&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;可追溯性&lt;/strong&gt;：每个版本都可以追踪到特定的提交，方便用户查看变更。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;文档&lt;/strong&gt;：可以为每个版本添加发布说明，告知用户新版本的功能和修复。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;整合&lt;/strong&gt;：与 Composer 的版本管理系统无缝集成，方便用户在 Composer 中安装特定版本。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;如何在 GitHub 上发布版本？&lt;/h3&gt;
&lt;h4&gt;创建一个标签：&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;## 在终端中导航到你的项目目录，并确保在你想要发布的版本上
git checkout main
## 创建一个 Git 标签，命名为你的版本号（例如 v1.0.0）
git tag v1.0.0
## 将标签推送到 GitHub
git push origin v1.0.0
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;在 GitHub 上创建发行版：&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;登录到你的 GitHub 仓库。&lt;/li&gt;
&lt;li&gt;点击“Releases”选项卡。&lt;/li&gt;
&lt;li&gt;点击“Draft a new release”按钮。&lt;/li&gt;
&lt;li&gt;选择刚刚创建的标签（例如 v1.0.0）。&lt;/li&gt;
&lt;li&gt;填写发行说明，简要描述该版本的新功能、修复或变更。&lt;/li&gt;
&lt;li&gt;点击“Publish release”按钮。&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;维护版本&lt;/h4&gt;
&lt;p&gt;每次更新你的代码并希望发布新版本时，重复以上步骤创建新的标签和发行版。&lt;/p&gt;
&lt;p&gt;你可以使用语义版本控制（如 MAJOR.MINOR.PATCH）来管理版本号。&lt;/p&gt;</content:encoded><h:img src="/_astro/php.DoApYH8-.png"/><enclosure url="/_astro/php.DoApYH8-.png"/></item><item><title>服务器文件变更同步小脚本</title><link>https://hejunjie.life/blog/44283ef4</link><guid isPermaLink="true">https://hejunjie.life/blog/44283ef4</guid><description>本文讲述了如何实现如何用 Go 来监测服务器文件变动，并自动同步到其他服务器</description><pubDate>Mon, 14 Oct 2024 08:15:41 GMT</pubDate><content:encoded>&lt;p&gt;最近发现用于为用户提供下载服务的服务器带宽承载压力逐渐增大，我建议使用阿里云的 OSS 来解决这个问题，毕竟 OSS 具备高效的存储和分发能力。&lt;/p&gt;
&lt;p&gt;然而，老板坚持选择多台服务器结合 CDN 进行内容分发。&lt;/p&gt;
&lt;p&gt;虽然这种方案可以缓解部分流量压力，但每次有内容更新时都需要手动同步到每台服务器。&lt;/p&gt;
&lt;p&gt;为了简化这一过程，我编写了一个自动同步的小脚本，来实现内容的快速分发&lt;/p&gt;
&lt;p&gt;话不多说直接上代码&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;当前脚本为检测特定目录下&lt;code&gt;.apk&lt;/code&gt;文件的变更，根据实际需要调整第&lt;strong&gt;66&lt;/strong&gt;行即可&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre&gt;&lt;code class=&quot;language-Go&quot;&gt;package main

import (
    &quot;fmt&quot;
    &quot;io&quot;
    &quot;log&quot;
    &quot;sort&quot;
    &quot;os&quot;
    &quot;os/exec&quot;
    &quot;path/filepath&quot;
    &quot;strings&quot;
    &quot;time&quot;

    &quot;github.com/fsnotify/fsnotify&quot;
)

var lastEventTime = make(map[string]time.Time)
var debounceDuration = 10 * time.Second // 可调整的去重时间间隔

// 监测路径
var rootDir = &quot;/www/wwwroot/ftp&quot;

// 目标服务器列表
var servers = []string{
    &quot;同步服务器1登录账号@同步服务器1IP:/&quot;,
    &quot;同步服务器2登录账号@同步服务器2IP:/&quot;,
    &quot;同步服务器3登录账号@同步服务器3IP:/&quot;,
    &quot;root@47.98.152.221:/&quot;,
}

// 子目录与目标路径的映射
var pathMap = map[string]string{
    &quot;本地服务器路径标识&quot;:     &quot;同步服务器路径（从跟路径起算）&quot;,
    &quot;本地服务器路径标识&quot;:     &quot;同步服务器路径（从跟路径起算）&quot;,
    &quot;Anxin&quot;:               &quot;www/wwwroot/Anxin&quot;,
}

func main() {
    // 设置日志输出到文件
    logFile, err := os.OpenFile(&quot;script.log&quot;, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
    if err != nil {
        log.Fatalf(&quot;无法打开日志文件: %v&quot;, err)
    }
    defer logFile.Close()
    log.SetOutput(logFile)

    // 创建文件监控器
    watcher, err := fsnotify.NewWatcher()
    if err != nil {
        log.Fatal(err)
    }
    defer watcher.Close()
    done := make(chan bool)
    go func() {
        for {
            select {
            case event, ok := &amp;#x3C;-watcher.Events:
                if !ok {
                    return
                }
                // 监测更多事件类型
                if (event.Op&amp;#x26;fsnotify.Write == fsnotify.Write ||
                    event.Op&amp;#x26;fsnotify.Create == fsnotify.Create ||
                    event.Op&amp;#x26;fsnotify.Rename == fsnotify.Rename ||
                    event.Op&amp;#x26;fsnotify.Chmod == fsnotify.Chmod) &amp;#x26;&amp;#x26;
                    strings.HasSuffix(event.Name, &quot;.apk&quot;) { // 判断是否是.apk文件
                    // 去重逻辑
                    if lastTime, exists := lastEventTime[event.Name]; exists {
                        if time.Since(lastTime) &amp;#x3C; debounceDuration {
                            // 如果上次处理时间在去重时间间隔内，跳过此事件
                            continue
                        }
                    }
                    lastEventTime[event.Name] = time.Now()
                    log.Printf(&quot;监测到文件变更: %s, 事件类型: %v\n&quot;, event.Name, event.Op)
                    // 调用 scp 同步文件
                    err := syncFile(event.Name)
                    if err != nil {
                        log.Printf(&quot;文件同步失败: %v&quot;, err)
                    }
                }
            case err, ok := &amp;#x3C;-watcher.Errors:
                if !ok {
                    return
                }
                log.Println(&quot;错误:&quot;, err)
            }
        }
    }()
    // 监控目录及子目录
    err = filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error {
        if err != nil {
            return err
        }
        if info.IsDir() {
            err = watcher.Add(path)
            if err != nil {
                log.Fatal(err)
            }
        }
        return nil
    })
    if err != nil {
        log.Fatal(err)
    }
    &amp;#x3C;-done
}

// syncFile 使用 scp 同步文件到多台服务器的不同路径，并复制到本地对应目录
func syncFile(filePath string) error {
    // 找到文件所属的子目录
    subDir := &quot;&quot;
    // 先按路径长度降序排序，确保长名称优先匹配
    for _, dir := range sortedKeys(pathMap) {
        if strings.Contains(filePath, dir) {
            subDir = dir
            break
        }
    }
    if subDir == &quot;&quot; {
        return fmt.Errorf(&quot;文件没有匹配的目标目录: %s&quot;, filePath)
    }
    // 获取同步目标路径
    targetPath := pathMap[subDir]
    fmt.Println(&quot;匹配目录:&quot;, targetPath)

    // 执行 scp 命令同步文件到每个服务器
    for _, server := range servers {
        // 组合目标路径
        destination := fmt.Sprintf(&quot;%s%s&quot;, server, targetPath)
        // 构建并执行 scp 命令
        cmd := exec.Command(&quot;scp&quot;, filePath, destination)
        // 捕获输出和错误信息
        output, err := cmd.CombinedOutput()
        if err != nil {
            log.Printf(&quot;执行 scp 命令失败: %v, 输出: %s&quot;, err, output)
            return err
        }
        log.Printf(&quot;文件 %s 同步到 %s\n&quot;, filePath, destination)
    }

    // 复制文件到本地对应的目录
    localDestination := filepath.Join(rootDir, targetPath)
    err := copyFileToLocal(filePath, localDestination)
    if err != nil {
        log.Printf(&quot;文件复制到本地失败: %v&quot;, err)
        return err
    }
    log.Printf(&quot;文件 %s 复制到本地目录 %s\n&quot;, filePath, localDestination)

    return nil
}

// copyFileToLocal 将文件复制到本地目录
func copyFileToLocal(srcPath, dstDir string) error {
    // 确保目标目录存在
    if err := os.MkdirAll(dstDir, os.ModePerm); err != nil {
        return fmt.Errorf(&quot;创建本地目录失败: %v&quot;, err)
    }
    // 获取文件名
    fileName := filepath.Base(srcPath)
    // 目标文件路径
    dstPath := filepath.Join(dstDir, fileName)
    // 打开源文件
    srcFile, err := os.Open(srcPath)
    if err != nil {
        return fmt.Errorf(&quot;打开源文件失败: %v&quot;, err)
    }
    defer srcFile.Close()
    // 创建目标文件
    dstFile, err := os.Create(dstPath)
    if err != nil {
        return fmt.Errorf(&quot;创建目标文件失败: %v&quot;, err)
    }
    defer dstFile.Close()
    // 复制文件内容
    _, err = io.Copy(dstFile, srcFile)
    if err != nil {
        return fmt.Errorf(&quot;复制文件内容失败: %v&quot;, err)
    }
    return nil
}

// sortedKeys 返回按长度降序排序的键列表
func sortedKeys(m map[string]string) []string {
    keys := make([]string, 0, len(m))
    for k := range m {
        keys = append(keys, k)
    }
    sort.Slice(keys, func(i, j int) bool {
        return len(keys[i]) &gt; len(keys[j])
    })
    return keys
}

&lt;/code&gt;&lt;/pre&gt;</content:encoded><h:img src="/_astro/go.aBeHx0xJ.jpg"/><enclosure url="/_astro/go.aBeHx0xJ.jpg"/></item><item><title>记录一次对CC攻击的处理</title><link>https://hejunjie.life/blog/202f6e55</link><guid isPermaLink="true">https://hejunjie.life/blog/202f6e55</guid><description>本文讲述了一次遭遇CC攻击的真实经历以及部分有效解决方案</description><pubDate>Tue, 08 Oct 2024 04:47:32 GMT</pubDate><content:encoded>&lt;p&gt;最近，我的 PHP 系统遭遇了一次 CC 攻击。这次攻击并不算特别猛烈，但足以对服务器造成明显的压力。由于从本质上来说，&lt;strong&gt;CC 攻击&lt;/strong&gt;和&lt;strong&gt;DDoS 攻击&lt;/strong&gt;都属于流量型和资源耗尽型的攻击，完全防御它们是不太现实的，尤其是在攻击量足够大的情况下。因此，结合这次的实际情况，系统做了一些调整和优化，以下是我尝试有效的几种应对策略。&lt;/p&gt;
&lt;h2&gt;使用阿里云全站加速（DCDN）&lt;/h2&gt;
&lt;p&gt;现在叫边缘安全加速（ESA）。&lt;/p&gt;
&lt;p&gt;这是一个非常经济实惠的解决方案，在加速访问的同时，CDN 还能帮助隐藏服务器的真实 IP 地址&lt;/p&gt;
&lt;p&gt;这对于防止 DDoS 攻击非常重要。因为 DDoS 攻击大多数是基于 IP 地址进行的，一旦服务器的 IP 地址暴露，就很容易成为攻击目标。&lt;/p&gt;
&lt;p&gt;而通过 CDN，攻击者只能找到 CDN 节点，攻击者获取不到源服务器的 IP。这有效减轻了 DDoS 攻击的可能性，让系统能够专注于应对应用层面的 CC 攻击。&lt;/p&gt;
&lt;h2&gt;阿里云的 WAF（Web 应用防火墙）&lt;/h2&gt;
&lt;p&gt;WAF 的核心功能就是在请求到达服务器之前进行一次安全检查，从而过滤掉明显的恶意请求。&lt;/p&gt;
&lt;p&gt;区域封禁+限速器组合使用，一方面可以封禁非业务地区（例如不存在海外业务时候无需处理来自境外的请求），一方面可以过滤掉明显异常的高频请求。&lt;/p&gt;
&lt;p&gt;这一步非常关键，因为 CC 攻击的目的是消耗服务器资源，一旦服务器资源被耗尽，整个系统就会陷入瘫痪。而 WAF 能够在流量进入服务器之前过滤掉这些恶意流量，从源头上减少服务器的压力。&lt;/p&gt;
&lt;h2&gt;使用 Webman 等高性能框架&lt;/h2&gt;
&lt;p&gt;除了外部的防护措施，高性能框架也是应对攻击的关键。在这次攻击处理中，我使用了 Webman 框架，这是一种常驻内存的 PHP 高性能框架。&lt;/p&gt;
&lt;p&gt;相比传统依靠 FPM（FastCGI Process Manager），每个请求都会重新加载应用程序和 PHP 环境，的框架&lt;/p&gt;
&lt;p&gt;Webman 使用了常驻内存模型，意味着 PHP 和框架只会在服务器启动时加载一次，之后的每个请求直接使用已经加载的内存，性能极高，可以显著提高每秒请求处理能力（QPS），使得服务器能在同等资源下承受更多的请求。&lt;/p&gt;
&lt;p&gt;Webman 在这次攻击中表现非常出色，在高并发下保持了较高的响应速度。即使是在遭遇 CC 攻击的情况下，服务器也没有完全被压垮，保证了部分正常用户的访问。&lt;/p&gt;
&lt;h2&gt;加密与鉴权机制&lt;/h2&gt;
&lt;p&gt;在接口设计上，我建议对所有请求都要做严格的加密与鉴权处理。这样即使攻击者发起了模拟请求，只要没有通过鉴权，他们就无法真正调用到系统的核心逻辑。&lt;/p&gt;
&lt;p&gt;通常推荐在加密数据中加入时间戳，这样每个请求都是一次性的。&lt;/p&gt;
&lt;p&gt;一方面可以防止因抓包重复请求消耗服务器资源的情况发生。&lt;/p&gt;
&lt;p&gt;另一方面因为时间的变化，即使是相同的内容在进行对称加密时每次的加密信息也不同，这可以有效增加加密信息被破解的难度。&lt;/p&gt;
&lt;p&gt;只有验证通过的请求才会被进一步处理。这个设计大大降低了恶意请求对系统的影响。&lt;/p&gt;
&lt;h2&gt;核心防护思路&lt;/h2&gt;
&lt;p&gt;经过这次攻击处理，我总结出一个核心思路：尽量不要让恶意请求真正触发服务器资源的消耗。能在外部拦住的请求就尽量在外部拦截，无法拦住的请求则依赖系统内部的鉴权与加密机制进行二次过滤。&lt;/p&gt;
&lt;p&gt;具体来说，通过 CDN 隐藏 IP，利用 WAF 过滤恶意流量，在应用层面通过高性能框架提高系统抗压能力，再配合加密和鉴权机制来阻止模拟请求成功，这些措施都可以有效减少 CC 攻击对服务器的影响。&lt;/p&gt;
&lt;h2&gt;总结&lt;/h2&gt;
&lt;p&gt;在面对 CC 攻击时，我们不可能做到百分之百的防御，尤其是当攻击量足够大时。但通过合理的防护手段，我们可以有效减少攻击带来的影响，保护系统的稳定运行。&lt;/p&gt;
&lt;p&gt;我相信只要合理利用这些工具，并根据业务需求做出相应的调整，任何开发者都可以在遭遇攻击时尽量减少损失。&lt;/p&gt;</content:encoded><h:img src="/_astro/web.n3Pk-HlC.jpg"/><enclosure url="/_astro/web.n3Pk-HlC.jpg"/></item><item><title>Webman 错误控制</title><link>https://hejunjie.life/blog/8f2a61e0</link><guid isPermaLink="true">https://hejunjie.life/blog/8f2a61e0</guid><description>在本文中，我们探讨了如何在 PHP 的 Webman 框架中通过继承 ExceptionHandlerInterface 自定义错误处理类</description><pubDate>Fri, 04 Oct 2024 07:49:36 GMT</pubDate><content:encoded>&lt;p&gt;在 Webman 中，控制错误返回可以通过全局异常处理器（&lt;code&gt;ExceptionHandler&lt;/code&gt;）来处理。你可以自定义异常处理逻辑，根据不同的异常类型或错误，返回不同的 HTTP 响应状态码和错误信息。以下是一个实现方式的示例：&lt;/p&gt;
&lt;h2&gt;自定义全局异常处理器&lt;/h2&gt;
&lt;p&gt;你可以定义自己的异常处理器，例如创建一个 &lt;code&gt;app/exception/Handler.php&lt;/code&gt; ，继承 Webman 提供的 &lt;code&gt;ExceptionHandlerInterface&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;&amp;#x3C;?php
namespace app\exception;

use Throwable;
use Webman\Http\Response;
use Webman\Http\Request;
use Webman\Exception\ExceptionHandlerInterface;

class Handler implements ExceptionHandlerInterface
{
    // 定义不需要记录日志的异常类型
    public $dontReport = [
        // 在这里可以定义不需要记录日志的异常类型
        \Illuminate\Validation\ValidationException::class,
        // 例如自定义业务逻辑异常
        \app\exception\BusinessException::class,
    ];

    /**
     * 处理异常的方法
     *
     * @param Request $request
     * @param Throwable $exception
     * @return Response
     */
    public function render(Request $request, Throwable $exception): Response
    {
        // 检查是否为自定义的业务异常
        if ($exception instanceof \app\exception\BusinessException) {
            return response(json([&apos;error&apos; =&gt; $exception-&gt;getMessage()], 400));
        }

        // 默认返回500错误
        return response(json([&apos;error&apos; =&gt; &apos;Server Error&apos;], 500));
    }

    /**
     * 记录异常日志的方法
     *
     * @param Throwable $exception
     * @return void
     */
    public function report(Throwable $exception)
    {
        // 如果异常不在 dontReport 中，就记录日志
        if (!$this-&gt;shouldntReport($exception)) {
            echo $exception; // 这里可以使用你喜欢的日志系统记录错误
        }
    }

    /**
     * 判断是否应该记录异常
     *
     * @param Throwable $exception
     * @return bool
     */
    protected function shouldntReport(Throwable $exception): bool
    {
        foreach ($this-&gt;dontReport as $type) {
            if ($exception instanceof $type) {
                return true;
            }
        }
        return false;
    }
}

&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;在配置文件中指定异常处理器&lt;/h2&gt;
&lt;p&gt;在 &lt;code&gt;config/exception.php&lt;/code&gt; 中，你可以指定这个异常处理器：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;return [
    &apos;exception_handler&apos; =&gt; app\exception\Handler::class,
];
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;自定义异常类&lt;/h2&gt;
&lt;p&gt;你可以根据需求定义自己的业务异常类，例如：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;&amp;#x3C;?php
namespace app\exception;

use Exception;

class BusinessException extends Exception
{
    public function __construct($message = &quot;业务逻辑异常&quot;, $code = 400)
    {
        parent::__construct($message, $code);
    }
}

&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;返回自定义错误响应&lt;/h2&gt;
&lt;p&gt;当某个操作出现业务错误时，你可以抛出自定义异常，比如：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;use app\exception\BusinessException;

throw new BusinessException(&quot;自定义的业务错误信息&quot;, 400);

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样，Webman 会根据你在全局异常处理器中定义的逻辑，返回相应的错误信息和 HTTP 状态码。&lt;/p&gt;
&lt;h2&gt;总结&lt;/h2&gt;
&lt;p&gt;通过自定义全局异常处理器，你可以灵活地控制 Webman 应用中的错误返回，能够根据不同的异常类型返回不同的错误信息，并记录相应的日志。&lt;/p&gt;</content:encoded><h:img src="/_astro/php.DoApYH8-.png"/><enclosure url="/_astro/php.DoApYH8-.png"/></item><item><title>Bilibili直播监控实现</title><link>https://hejunjie.life/blog/3bb1f08f</link><guid isPermaLink="true">https://hejunjie.life/blog/3bb1f08f</guid><description>本文讲述了如何从服务器端的方向，实现对哔哩哔哩直播间的弹幕监控，进房提醒，礼物自动答谢等功能的实现，以及自动录播并分段上传</description><pubDate>Sun, 15 Oct 2023 01:18:51 GMT</pubDate><content:encoded>&lt;h1&gt;哔哩哔哩直播监控实现&lt;/h1&gt;
&lt;p&gt;本文讲述了如何从服务器端的方向，实现对哔哩哔哩直播间的弹幕监控，进房提醒，礼物自动答谢等功能的实现，以及自动录播并分段上传&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;直播弹幕监控，自动欢迎进房，自动答谢礼物等功能来自 GitHub 中公开的项目：弹幕姬 &lt;a href=&quot;https://github.com/BanqiJane/Bilibili_Danmuji&quot;&gt;点击进入 GitHub&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;自动录播，视频分段来自 GitHub 中公开的项目：录播姬 &lt;a href=&quot;https://github.com/BililiveRecorder/BililiveRecorder&quot;&gt;点击进入 GitHub&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;自动投稿来自于 GitHub 中公开的项目：biliup-rs &lt;a href=&quot;https://github.com/biliup/biliup-rs&quot;&gt;点击进入 GitHub&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;本文主要在于记录这些项目部署的全过程，以及一些额外小功能的实现原理&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;录播姬&lt;/h2&gt;
&lt;h3&gt;下载&lt;/h3&gt;
&lt;p&gt;可以通过 &lt;a href=&quot;https://github.com/BililiveRecorder/BililiveRecorder/releases&quot;&gt;GitHub Release&lt;/a&gt; 页面中获取自己需要的版本进行下载&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;无特殊情况下，使用 BililiveRecorder-CLI-linux-x64 版本即可&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;| 操作系统 | 架构  | 下载链接                             |
| :------- | :---- | :----------------------------------- |
| Linux    | x64   | BililiveRecorder-CLI-linux-x64.zip   |
| Linux    | arm32 | BililiveRecorder-CLI-linux-arm.zip   |
| Linux    | arm64 | BililiveRecorder-CLI-linux-arm64.zip |
| Windows  | x64   | BililiveRecorder-CLI-win-x64.zip     |
| macOS    | x64   | BililiveRecorder-CLI-osx-x64.zip     |
| macOS    | arm64 | BililiveRecorder-CLI-osx-arm64.zip   |&lt;/p&gt;
&lt;h3&gt;安装&lt;/h3&gt;
&lt;p&gt;下载并解压压缩包，以 Linux x64 为例：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;mkdir &amp;#x3C;录播姬目录&gt;
cd &amp;#x3C;录播姬目录&gt;
# wget https://下载链接
unzip &amp;#x3C;下载到的 zip 压缩包&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;解压完成后，记得检查确认文件夹中的 BililiveRecorder.Cli 是否带有可执行权限，如果没有的话使用以下命令添加可执行权限：&lt;code&gt;chmod +x BililiveRecorder.Cli&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;确认录播姬可以运行、并检查版本号&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;./BililiveRecorder.Cli --version
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;后续录播姬的控制均由 BililiveRecorder.Cli 来处理，为了方便命令行的使用，可以将该文件缩写为其他名称，例如：brec ，方法如下：&lt;code&gt;mv BililiveRecorder.Cli brec&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;录播姬所有命令都可以加上 --help 查看帮助&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;运行&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;./brec run &quot;工作目录&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre&gt;&lt;code&gt;# 侦听本机地址，只有本地可以访问
./brec run --bind &quot;http://localhost:2356&quot; &quot;工作目录&quot;

# 或者所有设备都可访问
./brec run --bind &quot;http://*:2356&quot; &quot;工作目录&quot;

# HTTP Basic 登陆
./brec run --bind &quot;http://*:2356&quot; --http-basic-user &quot;用户名&quot; --http-basic-pass &quot;密码&quot; &quot;工作目录&quot;

# 使用录播姬自己生成的自签名证书
./brec run --bind &quot;https://*:2356&quot; &quot;工作目录&quot;

# 使用 pem 格式的证书，和 Nginx Caddy 等软件的证书格式一致
./brec run --bind &quot;https://*:2356&quot; --cert-pem-path &quot;证书文件路径&quot; --cert-key-path &quot;私钥文件路径&quot; &quot;工作目录&quot;

# 使用带密码的私钥
./brec run --bind &quot;https://*:2356&quot; --cert-pem-path &quot;证书文件路径&quot; --cert-key-path &quot;私钥文件路径&quot; --cert-password &quot;私钥密码&quot; &quot;工作目录&quot;

# 使用 pfx 格式的证书
./brec run --bind &quot;https://*:2356&quot; --cert-pfx-path &quot;证书文件路径&quot; &quot;工作目录&quot;

# 使用带密码的证书
./brec run --bind &quot;https://*:2356&quot; --cert-pfx-path &quot;证书文件路径&quot; --cert-password &quot;私钥密码&quot; &quot;工作目录&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;后续&lt;/h3&gt;
&lt;p&gt;录播姬需要保持后台持续运行，建议增加至 systemd 中并设置开机自动启动&lt;/p&gt;
&lt;h4&gt;创建服务&lt;/h4&gt;
&lt;p&gt;新建一个文件 /etc/systemd/system/brec.service&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[Unit]
Description=BililiveRecorder
After=network.target

[Service]
ExecStart=录播姬运行命令，注意使用根路径

[Install]
WantedBy=multi-user.target
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;重载服务&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;systemctl daemon-reload
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;设置开机自动启动&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;systemctl enable brec
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;禁止开机自动启动&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;systemctl disable brec
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;常用指令&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;# 查看运行状态
sudo systemctl status brec

# 启动
sudo systemctl start brec

# 停止
sudo systemctl stop brec

# 重启
sudo systemctl restart brec
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;其他&lt;/h3&gt;
&lt;p&gt;录播姬支持 webhook，部署成功后可以进行对应的设置
可以在直播开始/结束，文件录制开始/结束等节点对配置地址进行通知
&lt;a href=&quot;https://rec.danmuji.org/user/webhook&quot;&gt;点击查看相关文档&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;弹幕姬&lt;/h2&gt;
&lt;p&gt;该项目基于 Java 环境运行，安装前请先检查服务器是否安装 Java 以及检查 Java 的版本&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;建议 Java 版本 &gt;= 1.8&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Java 的安装方式网上有很多教程，本文不再中间描述，以 Centos 为例，官网下载对应的包&lt;/p&gt;
&lt;p&gt;在 /etc/profile 中增加对应的环境变量即可&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export JAVA_HOME=java目录
export PATH=$JAVA_HOME/bin:$PATH
export CLASSPATH=.:$JAVA_HOME/lib/dt.jar:$JAVA_HOME/lib/tools.jar
export JRE_HOME=$JAVA_HOME/jre
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;记得通过 &lt;code&gt;source /etc/profile&lt;/code&gt; 来使环境变量生效&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;下载&lt;/h3&gt;
&lt;p&gt;可以通过 &lt;a href=&quot;https://github.com/BanqiJane/Bilibili_Danmuji/releases&quot;&gt;GitHub Release&lt;/a&gt; 页面中获取自己需要的版本进行下载&lt;/p&gt;
&lt;p&gt;| 版本           | 说明                                   |
| :------------- | :------------------------------------- |
| danmuji        | 常规版本                               |
| danmuji-green  | windows 版本，无需 Java 环境，开包即用 |
| danmuji-docker | 全框架 docker 镜像构建版本             |&lt;/p&gt;
&lt;h3&gt;安装 &amp;#x26; 运行&lt;/h3&gt;
&lt;p&gt;该项目无需安装，直接运行 .jar 包即可，例如：&lt;code&gt;java -jar BilibiliDanmu.jar&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;需要关闭时，可以通过 &lt;code&gt;fuser -k -n tcp 对应端口&lt;/code&gt; 来进行关闭&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Biliup-rs&lt;/h2&gt;
&lt;p&gt;biliup-rs 是用 Rust 编写的，所以需要 &lt;a href=&quot;https://www.rust-lang.org&quot;&gt;安装 Rust&lt;/a&gt; 才能编译它&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;## 安装 Rust
curl --proto &apos;=https&apos; --tlsv1.2 -sSf https://sh.rustup.rs | sh

## 更新 Rust
rustup update
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;安装&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;cargo install biliup&lt;/code&gt;&lt;/p&gt;
&lt;h3&gt;使用&lt;/h3&gt;
&lt;p&gt;可以通过执行命令：&lt;code&gt;biliup -h&lt;/code&gt; 来查看可以进行的操作&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;USAGE:
    biliup [OPTIONS] &amp;#x3C;SUBCOMMAND&gt;

OPTIONS:
    -h, --help                         Print help information
    -u, --user-cookie &amp;#x3C;USER_COOKIE&gt;    登录信息文件 [default: cookies.json]
    -V, --version                      Print version information

SUBCOMMANDS:
    append    是否要对某稿件追加视频
    help      Print this message or the help of the given subcommand(s)
    login     登录B站并保存登录信息
    renew     手动验证并刷新登录信息
    show      打印视频详情
    upload    上传视频
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;其他&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;因为录播姬支持 webhook，当文件录制完成时可以触发回调&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;所以建议在本地做一个小脚本，当收到文件录制完成的通知时调用 biliup 上传录制完成的文件，实现自动上传&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://biliup.github.io/upload-systems-analysis.html&quot;&gt;B 站投稿线路分析&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://biliup.github.io/tid-ref.html&quot;&gt;B 站 tid 分区表&lt;/a&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
    &quot;cmd&quot;:&quot;POPULARITY_RED_POCKET_WINNER_LIST&quot;,
    &quot;data&quot;:{
        &quot;awards&quot;:{ ## 奖品数组
            &quot;31212&quot;:{
                &quot;award_big_pic&quot;:&quot;https://i0.hdslb.com/bfs/live/9e6521c57f24c7149c054d265818d4b82059f2ef.png&quot;, ## 奖品图片
                &quot;award_name&quot;:&quot;打call&quot;, ## 奖品名称
                &quot;award_pic&quot;:&quot;https://s1.hdslb.com/bfs/live/461be640f60788c1d159ec8d6c5d5cf1ef3d1830.png&quot;, ## 奖品图片
                &quot;award_price&quot;:500, ## 奖品价值，单位电池*100
                &quot;award_type&quot;:1 ## 待观察
            },
            &quot;31214&quot;:{
                &quot;award_big_pic&quot;:&quot;https://i0.hdslb.com/bfs/live/3b74c117b4f265edcea261bc5608a58d3a7c300a.png&quot;,
                &quot;award_name&quot;:&quot;牛哇&quot;,
                &quot;award_pic&quot;:&quot;https://s1.hdslb.com/bfs/live/91ac8e35dd93a7196325f1e2052356e71d135afb.png&quot;,
                &quot;award_price&quot;:100,
                &quot;award_type&quot;:1
            },
            &quot;31216&quot;:{
                &quot;award_big_pic&quot;:&quot;https://i0.hdslb.com/bfs/live/cf90eac49ac0df5c26312f457e92edfff266f3f1.png&quot;,
                &quot;award_name&quot;:&quot;小花花&quot;,
                &quot;award_pic&quot;:&quot;https://s1.hdslb.com/bfs/live/5126973892625f3a43a8290be6b625b5e54261a5.png&quot;,
                &quot;award_price&quot;:100,
                &quot;award_type&quot;:1
            }
        },
        &quot;lot_id&quot;:12803470, ## 红包id
        &quot;total_num&quot;:8, ## 待观察，应该是奖品数量
        &quot;version&quot;:1,
        &quot;winner_info&quot;:[
            [
                100422246, ## 用户uid
                &quot;呆呆是豆腐的好爸爸捏&quot;, ## 用户名称
                6549542, ## 待观察
                31212 ## 中奖礼物信息，对应礼物数组
            ],
            [
                244187937,
                &quot;一哭二闹三上佑娅&quot;,
                6450325,
                31212
            ],
            [
                33452712,
                &quot;纯爱猫猫丶&quot;,
                6537056,
                31214
            ],
            [
                258093224,
                &quot;凌小辰是小雏楠&quot;,
                6464920,
                31214
            ],
            [
                512011712,
                &quot;东哥不爱喝&quot;,
                6537057,
                31214
            ],
            [
                3494364166752651,
                &quot;鹤九-z&quot;,
                6458205,
                31216
            ]
        ]
    },
    &quot;is_report&quot;:false, ## 待观察
    &quot;msg_id&quot;:&quot;146118578543104&quot;, ## 待观察
    &quot;send_time&quot;:1688832579071 ## 推送时间
}
&lt;/code&gt;&lt;/pre&gt;</content:encoded><h:img src="/_astro/bilibili.p76c9pFG.jpg"/><enclosure url="/_astro/bilibili.p76c9pFG.jpg"/></item></channel></rss>