<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Hamster1963]]></title><description><![CDATA[开发者、业余吉他手。]]></description><link>https://buycoffee.top</link><image><url>https://buycoffee.top/avatar.webp</url><title>Hamster1963</title><link>https://buycoffee.top</link></image><generator>Next.js</generator><lastBuildDate>Wed, 29 Apr 2026 04:05:00 GMT</lastBuildDate><atom:link href="https://buycoffee.top/rss" rel="self" type="application/rss+xml"/><language><![CDATA[zh-CN]]></language><follow_challenge><feedId>52340201851637764</feedId><userId>56627392544571392</userId></follow_challenge><item><title><![CDATA[2025 Blog Redesign]]></title><description><![CDATA[<p>嗨👋！很久没和大家见面，临近年底，寒冷的空气和繁忙的工作大大降低了创作的欲望，但也是在这种氛围下，可以让我回过头去重新看看先前的项目。</p>
<p>于是这个月以来，陆陆续续把博客翻新了一下，如果说装修的过程是创造，那翻新的过程就是取舍，就让我介绍一下这个“翻新”的博客。</p>
<h2>导航栏</h2>
<p><img src="/blog-img/blog-2025-redesign/CleanShot_2024-11-27_at_11.08.572x.png" alt="CleanShot 2024-11-27 at 11.08.57@2x.png"></p>
<p>第一眼注意到的应该是导航栏的重新设计。</p>
<p>在先前开源的博客框架 Nextme 中，导航栏放置在底部，采用图标加文字的方式呈现。</p>
<p><img src="/blog-img/blog-2025-redesign/CleanShot_2024-11-27_at_11.10.312x.png" alt="alt: 先前的导航栏"></p>
<p>这种设计更偏向实用性，在切换页面的时候也有一个简单的过渡动画指示。</p>
<p>但在阅读文章时，尤其在移动端上，导航栏与路径栏的位置过于靠近，在屏幕上整体的视觉风格会让人比较有压力，也降低了阅读的体验。</p>
<p>&#x3C;img src="/blog-img/blog-2025-redesign/IMG_4503-portrait.png" alt="移动端阅读体验" style={{maxWidth: '300px', margin: '0 auto', borderRadius: '8px'}} /></p>
<p>因此在翻新的过程中，采用了激进的的方式：</p>
<ol>
<li>剔除导航栏中的文字，只保留图标</li>
<li>将导航栏的位置从底部移动至顶部</li>
<li>剔除不必要的模糊效果，采用渐变的方式来提高导航栏与内容的对比度。</li>
</ol>
<p>于是便有了目前新版的导航栏，在新的导航栏下，阅读时不会再去干扰底部内容的可读性，同时更简洁的导航栏也为以后的新增页面预留了更多的空间。</p>
<p>&#x3C;img src="/blog-img/blog-2025-redesign/IMG_4504-portrait.png" alt="移动端阅读体验" style={{maxWidth: '300px', margin: '0 auto', borderRadius: '8px'}} /></p>
<h2>首页</h2>
<h3>欢迎动画</h3>
<video width="100%" class="rounded-xl" autoplay loop muted playsinline>
  <source src="/blog-img/blog-2025-redesign/CleanShot_2024-11-20_at_18.21.35.mp4" type="video/mp4">
</video>
<p>在翻新导航栏后，下一步则是添加了首页的欢迎效果。</p>
<p>在首次进入首页时，在顶部会出现一个类似彩虹聚光灯的效果，表示对来访的欢迎👏。</p>
<blockquote>
<p>💡这个效果的灵感来自于 Vercel 部署成功后，顶部出现的彩虹条纹提示。</p>
</blockquote>
<p><img src="/blog-img/blog-2025-redesign/CleanShot_2024-11-27_at_11.36.112x.png" alt="CleanShot 2024-11-27 at 11.36.11@2x.png"></p>
<p>这种提示或者说庆祝效果，不侵入用户的页面内容，十分的自然美观。</p>
<p>在代码实现上，出现与消失效果通过 framer-motion 控制，内部的颜色变换效果通过 css 实现。</p>
<pre><code class="language-bash">'use client'

import { useStatus } from 'lib/status-context'
import { AnimatePresence, m } from 'framer-motion'

export default function AnimatedHeader() {
  const { status } = useStatus()

  return (
    &#x3C;AnimatePresence>
      {status &#x26;&#x26; (
        &#x3C;m.div
          initial={{ opacity: 0 }}
          animate={{ opacity: 1 }}
          exit={{ opacity: 0 }}
          transition={{ duration: 0.8 }}
          className="pointer-events-none z-999 flex h-50 w-50 items-center justify-center"
        >
          &#x3C;div className="fixed -top-52 right-0 left-0 flex h-[300px] items-center justify-center overflow-hidden blur-[80px] saturate-150">
            &#x3C;div className="animate-orbit absolute h-[500px] w-[500px]">
              &#x3C;div className="absolute top-[125px] left-[125px] w-[250px] rounded-full bg-sky-500 pb-[250px]">&#x3C;/div>
            &#x3C;/div>
            &#x3C;div className="animate-orbit2 absolute h-[250px] w-[500px]">
              &#x3C;div className="absolute top-[50px] left-[125px] w-[200px] rounded-full bg-fuchsia-500 pb-[200px]">&#x3C;/div>
            &#x3C;/div>
            &#x3C;div className="animate-orbit3 absolute h-[500px] w-[500px]">
              &#x3C;div className="absolute top-[250px] left-[150px] w-[150px] rounded-full bg-cyan-400 pb-[150px]">&#x3C;/div>
            &#x3C;/div>
            &#x3C;div className="animate-orbit4 absolute h-[500px] w-[250px]">
              &#x3C;div className="absolute top-[125px] left-[62.5px] w-[150px] rounded-full bg-green-400 pb-[150px]">&#x3C;/div>
            &#x3C;/div>
          &#x3C;/div>
        &#x3C;/m.div>
      )}
    &#x3C;/AnimatePresence>
  )
}
</code></pre>
<p>在 className 中使用了 animate-orbit 动画，其实就是一个简单的 spin 效果，在 <code>tailwind.config.ts</code> 中定义：</p>
<p><img src="/blog-img/blog-2025-redesign/ray-so-export.png" alt="ray-so-export.png"></p>
<p>从整体的角度看，就是四个圆形在变换位置，从而体现颜色渐变的效果。</p>
<video width="100%" class="rounded-xl" autoplay loop muted playsinline>
  <source src="/blog-img/blog-2025-redesign/CleanShot_2024-11-27_at_11.47.10.mp4" type="video/mp4">
</video>
<p>最后将这个效果固定在顶部，只显示出底部的部分，就完成了这个欢迎动画效果。</p>
<h3>歌曲切换</h3>
<p>now-playing 组件在歌曲切换时也有了动画。</p>
<video width="100%" class="rounded-xl" autoplay loop muted playsinline>
  <source src="/blog-img/blog-2025-redesign/now-playing.mp4" type="video/mp4">
</video>
<p>通过 framer-motion ，使用 <code>&#x3C;AnimatePresence/></code> 与 <code>&#x3C;m.div/></code> 将封面与歌曲名称分别包裹起来，在切换时，通过修改 id 来触发切换动画效果。</p>
<p><img src="/blog-img/blog-2025-redesign/ray-so-export-2.png" alt="ray-so-export-2.png"></p>
<p>在 <code>&#x3C;AnimatePresence/></code> 中，通过设置 <code>initial</code> 为 <code>false</code> 来避免首次进入页面的触发动画。</p>
<p>同时，需要将 <code>mode</code> 设置为 <code>“wait”</code>，避免退出未完成时就有新动画插入。</p>
<h2>实时指针</h2>
<p>在先前的文章中，介绍过博客站点中的实时指针：</p>
<p><a href="https://buycoffee.top/blog/tech/cursor">博客里的实时指针</a></p>
<p>在这次翻新中，通过两个方面改进了用户体验：</p>
<ol>
<li>指针外观</li>
<li>显示逻辑</li>
</ol>
<p>在先前的版本中，采用较浅与低饱和度的颜色作为指针颜色，虽然观感不错，但与博客内使用的色彩并不搭配。</p>
<p>因此目前全部都采用了 Tailwind CSS 中定义的颜色，选用了较深的颜色以在浅色与暗色模式下都有着不错的观感。</p>
<p>同时，在指示器上增加一个内阴影与文字阴影，带来一些立体的效果。</p>
<p><img src="/blog-img/blog-2025-redesign/CleanShot_2024-11-27_at_22.04.592x.png" alt="CleanShot 2024-11-27 at 22.04.59@2x.png"></p>
<p>在现实逻辑上也进行了优化，先前在全部的页面都会显示本地的指针指示器，而页面内是否有其他的在线用户实际上是不可知的。</p>
<p>因此，在翻新后的逻辑下，只有当前浏览页面存在着其他在线用户，指示器才会显示出来。</p>
<p>所以当你在某个页面发现鼠标旁出现了指示器，按下 / 与其他用户打个招呼吧👋。</p>
<h2>悬浮操作栏</h2>
<p>在翻新的过程中，最有意思的就是各式的悬浮操作栏了，先前已在更新提示中实现：</p>
<p><a href="https://buycoffee.top/blog/tech/site-update">为站点增加更新提示</a></p>
<p>而现在的悬浮操作栏拓展到了评论的操作上：</p>
<video width="100%" class="rounded-xl" autoplay loop muted playsinline>
  <source src="/blog-img/blog-2025-redesign/CleanShot_2024-11-27_at_22.15.49.mp4" type="video/mp4">
</video>
<p>相同的，动画由 framer-motion 控制，通过定义初始与退出动画来实现悬浮工具栏效果。</p>
<p><img src="/blog-img/blog-2025-redesign/ray-so-export-3.png" alt="ray-so-export-3.png"></p>
<h3>景深效果按钮</h3>
<p>扁平的按钮看多了确实有些无趣，虽然简洁，但是缺少了一些灵动和有趣。</p>
<p><img src="/blog-img/blog-2025-redesign/CleanShot_2024-11-27_at_22.45.102x.png" alt="CleanShot 2024-11-27 at 22.45.10@2x.png"></p>
<p>搭配内阴影与文字阴影皆可给按钮带来一些立体的感觉，当鼠标移至按钮上，仿佛真有按下的错觉。</p>
<video width="100%" class="rounded-xl" autoplay loop muted playsinline>
  <source src="/blog-img/blog-2025-redesign/CleanShot_2024-11-27_at_22.39.48.mp4" type="video/mp4">
</video>
<p><img src="/blog-img/blog-2025-redesign/CleanShot_2024-11-27_at_22.50.512x.png" alt="CleanShot 2024-11-27 at 22.50.51@2x.png"></p>
<p>在实现上，在 Tailwind CSS v3 版本中，并没有对应可用的类，但我们可通过自定义内阴影与直接定义样式来实现这两个样式，在 className 中，通过对 shadow 类进行自定义，在按钮文字上，直接传入文字阴影样式。</p>
<p><img src="/blog-img/blog-2025-redesign/ray-so-export-4.png" alt="ray-so-export-4.png"></p>
<h2>新页面</h2>
<p>本次翻新还带来了一个新页面，我称之为 Darkroom（暗室），用以展示一些博客数据与后端服务信息。</p>
<p><img src="/blog-img/blog-2025-redesign/CleanShot_2024-11-27_at_22.56.422x.png" alt="CleanShot 2024-11-27 at 22.56.42@2x.png"></p>
<p>数据从 prometheus 中获取，通过 SSE 的方式实时流式传输到博客前端。</p>
<p>访客位置则是通过 cloudflare 传递的 Header 中获取。</p>
<p><img src="/blog-img/blog-2025-redesign/ray-so-export_(1).png" alt="ray-so-export (1).png"></p>
<p>欢迎多来 Darkroom 转转，有许多来自世界不同地方进行访问站点的朋友😄。</p>
<h2>性能</h2>
<p>性能永远都十分重要，无论加入了多少新功能。</p>
<h3>Webp</h3>
<p>趁着翻新，将博客全部的图片都压缩更新为了 webp 格式，不仅可以大幅降低图片存储空间占用，也带来了更快的页面访问速度。</p>
<h3>Next.js 15 + React 19</h3>
<p>升级后采用 turbopack 进行本地的开发与调试，构建时采用了 React 编译器，带来了不错的开发性能与编译速度的提升。</p>
<h3>Tailwind CSS v4 + inlineCss</h3>
<p>借助 Tailwind CSS v4 带来的新编译引擎，开发中页面对于样式的修改更加迅速了。</p>
<p><img src="/blog-img/blog-2025-redesign/CleanShot_2024-11-28_at_09.33.282x.png" alt="CleanShot 2024-11-28 at 09.33.28@2x.png"></p>
<p>同时，在 <code>next.config.mjs</code> 配置文件中，启用实验性特性 <code>inlineCss</code> ，可降低用户首次打开页面时 FCP（<strong>First Contentful Paint</strong>） 所需要的时间。</p>
<p><img src="/blog-img/blog-2025-redesign/ray-so-export%201.png" alt="ray-so-export.png"></p>
<h3>性能监测</h3>
<p>在经过这些优化处理并观察一段时间后，博客站点的性能保持在了不错的水平😄。</p>
<p><img src="/blog-img/blog-2025-redesign/CleanShot_2024-11-28_at_09.39.492x.png" alt="CleanShot 2024-11-28 at 09.39.49@2x.png"></p>
<h2>取舍</h2>
<p>在这次的翻新中，为了更好的性能，取消了常驻的模糊效果，与一些页面的大范围动画效果，尽可能让动画融入到页面内容中，尽量不让用户明确感知到动画的存在。</p>
<p>虽然站点增加了一些笨重感，但长远来看，简洁的设计、充实的内容再加上一些小彩蛋，才让一个博客站点有其独特的意义。</p>
<p>感谢你的阅读，我们 2026 再见，装修还是翻新？那得取决于上班摸鱼的时间。</p>]]></description><link>https://buycoffee.top/blog/tech/2025-redesign</link><guid isPermaLink="true">https://buycoffee.top/blog/tech/2025-redesign</guid><dc:creator><![CDATA[Hamster1963]]></dc:creator><pubDate>Thu, 28 Nov 2024 00:00:00 GMT</pubDate><enclosure url="https://buycoffee.top/blog-img/blog-2025-redesign/cover.jpeg" length="0" type="image/jpeg"/></item><item><title><![CDATA[Beeper 使用体验]]></title><description><![CDATA[<h2>漫长的等待</h2>
<p>在经历了一年半的等待后，在某个深夜决定入睡的前5分钟，我终于收到了来自Beeper官方发来的内测申请。作为一个对科技产品，尤其是对新的硬件与软件尤为热衷的geek，三下五除二马上翻开电脑进行了安装与试用。但等待的兴奋感在安装软件体验十分钟后就消散了，试用的结论就是: 还是用回独立软件吧。</p>
<p><img src="/blog-img/beeper/Untitled.png" alt="Untitled"></p>
<h2>Beeper</h2>
<p>两年前一个YouTube的广告吸引了我，在一个软件中你可以回复telegram，可以回复ins，可以回复iMessage，这是什么神仙科技，当时便在官网进行了注册，不出意料，想要获得试用，你必须加入waitlist并需要经过可能漫长也可能极短的等待。在申请完后，我开始去了解Beeper软件的发展。</p>
<p>Beeper实际上是上个世纪在移动通讯的发展中不可忽视的一个小物件，中文应该叫做寻呼机，在广东，或者香港这边我们常常叫做BB机，这便是Beeper词语背后最直接的意思。</p>
<p><img src="/blog-img/beeper/Untitled%201.png" alt="Untitled"></p>
<p>而对应于产品背后的含义，我想Beeper的团队肯定想到了对应寻呼机后面的使用习惯。一个物品集合了全部的通讯，同时通过系统给出的信息可以在电话亭再去进行相对应的联络，我想这便也是Beeper App的核心构建逻辑。</p>
<p><img src="/blog-img/beeper/Untitled%202.png" alt="Untitled"></p>
<p>All In One的想法看起来很美好，但是对于程序员而言则绝对是地狱难度级别的项目，通讯软件通常是所有实时类软件中对于隐私控制最为严格的软件之一，通常各大通讯软件的厂商并不会直接向外提供可以获取通信信息的SDK或API，而通过其他途径获取的数据也很可能会遭到第三方的监听或者篡改，因此一个软件集合全部的通讯软件并将功能进行对接，作为一个菜鸡程序员，这是一个听到产品说完第一句话就会马上跑路的项目。那Beeper是如何做到的？</p>
<h2>Matrix协议</h2>
<p>Beeper的核心技术是一个叫做Matrix的开源通讯协议，Matrix主要是一套用以去中心化通信的API，可以使用Matrix来构建类似即时消息，IP语音或者IoT这类的通信系统，Matrix将其的作用定义为桥，目的是为新的开放式实时通信生态系统奠定基础。同时其中数据的交互都是通过JSON数据以Restful API的形式进行传递的，这对于目前的开发人员显得十分友好。</p>
<p><img src="/blog-img/beeper/Untitled%203.png" alt="Untitled"></p>
<p>如此看来Beeper选用Matrix协议的理由就十分充分了，归根结底Beeper其实是一个巨大的中转站，将所有需要发送的消息传递给Beeper，Beeper再通过Matrix协议转发给不同的通讯软件。</p>
<p>那就只剩下最后一个问题，怎么和其他的私有加密协议的通讯软件进行对接的？</p>
<h2>Beeper Web</h2>
<p>Beeper Web承担了将需要发送的消息解密后重新使用平台（iMessage/Signal）私有加密协议进行加密并将数据传输至平台的功能。听起来很绕，但只是Beeper在后端重新实现了对应平台的私有加密协议算法，而这则是Beeper软件在代码构建上的核心杀手锏。</p>
<p>在2020年Beeper公司创始人<strong>Eric Migicovsky</strong>发表的博客<strong>The Universal Communication Bus</strong>中他提到，他想在一套系统上与全部社交平台上的好友进行联络，但是没有这样的软件，因此他便亲自来创建这样的一个APP，听上去是一个野心很大的创业项目，那Beeper的实际体验如何呢？</p>
<h2>挠痒痒般的体验</h2>
<p>在官网下载安装包后，你会发现安装包的大小仅有3MB，不知道是哪里来的预感，我猜Beeper的桌面端肯定是采用<em>Electron</em>技术进行构建的，事实也没有打我的脸，在经历漫长的下载后Beeper终于安装进了电脑中，在没有进行任何聊天的情况下，Beeper的占用体积就达到了惊人的985.6MB，莫非Beeper的app in one是指在那么大的占用体积中将我用的通讯APP都偷偷安装在内吗？！虽然知道这锅得<em>Electron</em>背但在还没有打开软件的情况下也属实给了我一个国产厂商震撼。</p>
<p>进入Beeper后，首先得进行登录，输入邮箱，点击邮箱中的跳转链接进行登录，方式中规中矩。登录完成后进行最重要的一步，不同软件的绑定。</p>
<p><img src="/blog-img/beeper/Untitled%204.png" alt="Untitled"></p>
<p>绑定各类软件的体验并不能算是傻瓜式的绑定，例如ins与twitter都需要设置繁琐的二部验证后再去邮箱中进行绑定，而类似iMessage则是需要登录后再在手机设置中增加iMessage的消息转发，通常在添加这一步需要用到的耗时就有10分钟之久。</p>
<p>而在软件的体验上，Beeper也并不算太好，首先是资源占用，在打开Beeper5分钟后，我的电脑温度就飙升到了71度，同时CPU的占用率也在不断上升中，目前Beeper对于软件性能的优化估计由于功能的未完善因此优先度也不高。</p>
<p><img src="/blog-img/beeper/Untitled%205.png" alt="Untitled"></p>
<p>Beeper的软件首页分为三个大块，左侧为不同软件的分类，中间为对应的对话列表，右侧则是具体的对话内容，从界面的设计美观上与大多数的软件持平，但在交互上Beeper并没有太多的动画，而且在按钮的可见性与交互上也不能算是很完善，在许多地方常常会出现有一个按钮但完全不知道其作用的情况。</p>
<p><img src="/blog-img/beeper/Untitled%206.png" alt="Untitled"></p>
<p>同时在具体的聊天内容设计上，颜色的设计与字体大小也透露出目前Beeper还处于非常早期的阶段，没有经过太多的设计思考与打磨。在聊天的交互上，Beeper有点食之无味二弃之可惜。</p>
<p><img src="/blog-img/beeper/Untitled%207.png" alt="Untitled"></p>
<h2>功能的残缺</h2>
<p>在telegram对应的模块中，没有评论功能，不能获取到部分的群组与没有机器人功能。</p>
<p>在iMessage中，缺少了原生iMessage中那些额外的插件功能也显得有点索然无味。</p>
<p><img src="/blog-img/beeper/Untitled%208.png" alt="Untitled"></p>
<h2>结论</h2>
<p>有的时候可能你以为全部人都需要的核心需求反而只是一小部分人的自嗨，我不否认Beeper软件在统一通讯软件上所付出的努力，但在体验过后，我想这个软件可能只适合那些由于各种原因需要在不同的交友软件上进行交友后需要在一个地方统一管理的人，如果只看简单的通讯，Beeper显然已经可以满足需求，但正是那些特色的功能我们选择了用不同的软件进行沟通，很多时候例如使用ins，twitter并不只是单纯为了简单的文字聊天，我们会利用到平台内的内容进行分享，例如分享推文或者ins的图片，或我们非常喜爱的telegram的机器人，正是这些独特的功能使得我们有力气在不同的软件中进行穿梭而不觉得疲劳。而Beeper目前对于我而言，想要突破这个乏味的使用体验并真正变成一个all in one，还有很长的路要走。</p>]]></description><link>https://buycoffee.top/blog/tech/beeper</link><guid isPermaLink="true">https://buycoffee.top/blog/tech/beeper</guid><dc:creator><![CDATA[Hamster1963]]></dc:creator><pubDate>Sun, 23 Jul 2023 00:00:00 GMT</pubDate><enclosure url="https://buycoffee.top/blog-img/beeper/cover.jpeg" length="0" type="image/jpeg"/></item><item><title><![CDATA[2024 Blog Refresh]]></title><description><![CDATA[<blockquote>
<p>The code for this blog site was inspired by Next.js Portfolio Starter.
<a href="https://vercel.com/templates/next.js/portfolio-starter-kit">Portfolio Starter Kit – Vercel</a></p>
</blockquote>
<p>从 2021 年开始折腾博客到现在，已经是来到第三个年头了，这三年博客从外观到技术栈的实现都发生了翻天覆地的变化。</p>
<h2>2021-Hexo</h2>
<p>&#x3C;TechCard
techList={[
{
category: 'Frontend',
tech: 'Hexo',
},
{
category: 'Content',
tech: 'markdown',
},
]}
/></p>
<p>2020 年底我开始接触前端，从 HTML CSS JS 三件套开始学起，对于前端的各种复杂布局和框架还是一头雾水，于是在 2021 年看到许多人用 hexo 构建博客时，我也一头扎进 hexo 的大家庭中。</p>
<p><a href="https://blog-v0.buycoffee.top/">仓鼠杂货铺v0</a></p>
<p><img src="/blog-img/blog-2024/Untitled%201.png" alt="Untitled"></p>
<p>Hexo 对于新手来说上手的成本很低，只需跟着文档一步步进行即可创建一个博客，同时开源社区中有大量可供选择的主题，在早期对前端了解未深入的时候，Hexo 就让我体验了一把创造属于自己的玩具的快乐。</p>
<p>Hexo 是通过生成静态文件的方式来进行部署。</p>
<p>Hexo 通过遍历 source 目录，建立索引，再根据索引生成纯静态文件放在 public 文件夹中，而使用者通过部署 public 中的文件到 VPS，云服务，或者 Vercel 这类平台上。</p>
<p>而这种方式的优缺点也很明显：</p>
<p>优点：</p>
<ul>
<li>全为静态文件，部署方便。</li>
<li>可使用 CDN 加速几乎全部的静态文件。</li>
</ul>
<p>缺点：</p>
<ul>
<li>不支持动态内容，在客户端的 JS 逻辑较为繁重。</li>
<li>生成时间随着内容增长而增长，比较影响生成效率。</li>
</ul>
<p>但 Hexo 陪伴我走过了那段初学者的时间，不仅让我第一次体验亲手去改动插件的源码，也让我学习到许多关于前端部署，CDN 相关的知识。</p>
<h2>2023-Next.js+Sanity</h2>
<p>&#x3C;TechCard
techList={[
{
category: 'Frontend',
tech: 'Next.js',
},
{
category: 'Content',
tech: 'Sanity',
},
{
category: 'Animation',
tech: 'Framer Motion',
},
{
category: 'Style',
tech: 'Tailwind CSS',
},
]}
/></p>
<p>在 2023 年，因为工作的关系我重新对最新的前端知识进行学习，了解到了许多现代化的框架，比如</p>
<ul>
<li>Next.js</li>
<li>Remix</li>
<li>Svelte</li>
</ul>
<p>这些框架都在不同方面满足了现代前端的开发需求，而其中 Next.js 是其中发展最快且开发者最多的框架，因此我又一头扎进了 Next.js 的海洋中。</p>
<p>在学习的过程中，我无意间看到 Cali 的开源博客文章，便抱着尝试的态度进行了部署与个性化的修改。</p>
<p><a href="https://blog-v1.buycoffee.top/">仓鼠杂货铺v1</a></p>
<p><img src="/blog-img/blog-2024/Untitled%203.png" alt="Untitled"></p>
<p>优点</p>
<ul>
<li>优秀的用户认证</li>
<li>优秀的交互动画</li>
<li>优秀的 SEO</li>
</ul>
<p>缺点</p>
<ul>
<li>SSR 导致首屏没有被 CDN 缓存，首屏显示延迟增加。</li>
<li>使用大量动画导致客户端的 JS 负担过重。</li>
<li>内容管理依附于 sanity，灵活度不足。</li>
</ul>
<p>Cali 的博客设计风格十分出众，内容层级也很清晰，但毕竟不是自己的东西，在别人的框架上修修改改始终觉得差点意思.</p>
<p>在折腾的过程中我对 Next.js 与 SSR ISR PPR 相关的概念和实做不再模糊，而是可以自己上手去调试。</p>
<p>于是，在折腾的过程中还顺便帮 Cali 修了个小 BUG。</p>
<p><a href="https://github.com/CaliCastle/cali.so/pull/29">https://github.com/CaliCastle/cali.so/pull/29</a></p>
<p>对于我而言，我习惯将博客先在 notion 编写后，再同步至博客中，如果可以在博客中再加入一些自定义的组件或者样式就再好不过了。</p>
<p>但目前基于 sanity 的方案下，只能通过 sanity 获取基础的内容，如果需要更加灵活的样式和排版则很难实现。</p>
<p>因此在某一个使用 sanity 的编辑器抓耳挠腮编写博客的夜晚，我决定重头开始编写属于自己的博客。</p>
<h2>2024-Next.js+MDX</h2>
<p>&#x3C;TechCard
techList={[
{
category: 'Frontend',
tech: 'Next.js',
},
{
category: 'Content',
tech: 'MDX',
},
{
category: 'Animation',
tech: 'Framer Motion',
},
{
category: 'Style',
tech: 'Tailwind CSS',
},
]}
/></p>
<p>在重新开始设计与构建新的博客站点时，我尤为关心以下几点：</p>
<ol>
<li>Local MDX file management - 本地 MDX 文件管理</li>
<li>Performance - 性能</li>
<li>UI - 界面 UI</li>
</ol>
<p><em>Let's Dive in</em>!</p>
<h3>本地 MDX 文件管理</h3>
<p><img src="/blog-img/blog-2024/Untitled%204.png" alt="Untitled"></p>
<p>在尝试过 hexo 的渲染引擎与 sanity 的在线编辑器后，我开始思考，是否有兼顾便捷与灵活性的解决方案？</p>
<p>在浏览 Next.js 的官方文档时，我注意到这篇介绍 markdown 与 MDX 的文章。</p>
<p><a href="https://nextjs.org/docs/app/building-your-application/configuring/mdx">MDX</a></p>
<blockquote>
<p><a href="https://mdxjs.com/">MDX</a> is a superset of markdown that lets you write <a href="https://react.dev/learn/writing-markup-with-jsx">JSX</a> directly in your markdown files. It is a powerful way to add dynamic interactivity and embed React components within your content.</p>
</blockquote>
<p>总的来说 MDX 是 markdown 格式的超集，并且可以直接在 MDX 文件中编写 JSX 组件。</p>
<p><strong>MDX</strong> 可以很好的兼顾本地存储与灵活添加各种组件的需求，并且对于习惯 markdown 格式的我来说几乎没有上手成本。</p>
<p>不仅如此，MDX 可以通过不同的方式进行渲染成页面，我既可以直接使用 <code>@next/mdx</code> 库来在构建时构建成页面，也可以通过<code>next-mdx-remote</code>库在运行时渲染云端的 MDX 文件。</p>
<p><img src="/blog-img/blog-2024/Untitled%205.png" alt="Untitled"></p>
<p>同时, MDX 对 markdown 的兼容使得我可以很轻松的将 notion 上已经编写好的内容直接迁移至对应的 MDX 文件中，并且还可以在一些需要更加个性化的内容时，通过编写 JSX 组件的形式来添加到文章中。</p>
<p><img src="/blog-img/blog-2024/Code_to_image.png" alt="Code to image.png"></p>
<p>最终选用 MDX 格式存储在本地文件中，使用 git 进行版本管理，在渲染方式上选用<code>next-mdx-remote</code>库。</p>
<p>这不仅可以保留一定的灵活性，同时对于当前的本地存储方式，也可以直接使用 SSG 的方式，在构建时直接编译为静态页面，可以很好被 CDN 捕获与加速。</p>
<p><img src="/blog-img/blog-2024/Code_page.png" alt="Code page.png"></p>
<h3>性能</h3>
<p><img src="/blog-img/blog-2024/Untitled%206.png" alt="Untitled"></p>
<p>性能不仅对于用户体验十分重要，对于 SEO 也是十分重要的，详情可见这篇文章：</p>
<p><a href="https://vercel.com/blog/how-core-web-vitals-affect-seo">how-core-web-vitals-affect-seo</a></p>
<p>文章中提到 <strong>Core Web Vitals</strong> 是十分重要的，不仅仅会反映用户的实际站点体验，还会影响 SEO 的优先程度。</p>
<blockquote>
<p>The Core Web Vitals report shows URL performance grouped by <a href="https://support.google.com/webmasters/answer/9205520?hl=en#status_explanation">status</a> (Poor, Need improvement, Good), <a href="https://support.google.com/webmasters/answer/9205520?hl=en#status_bucket">metric type</a> (CLS, INP, and LCP), and <a href="https://support.google.com/webmasters/answer/9205520?hl=en#page_groups">URL group</a> (groups of similar web pages).</p>
</blockquote>
<p>而对于站点响应速度，应该关注的指标有：</p>
<ul>
<li>First Contentful Paint <strong>首次内容绘制</strong></li>
<li>Speed Index <strong>速度指数</strong></li>
</ul>
<p>在开始构建之前，先看一下之前的旧版博客在 <strong><em>Lighthouse</em></strong> 中的得分吧。</p>
<p><img src="/blog-img/blog-2024/Untitled%207.png" alt="Untitled"></p>
<p><img src="/blog-img/blog-2024/Untitled%208.png" alt="Untitled"></p>
<p>可以看到，旧版的博客在性能表现上都不太好，主要的原因有：</p>
<ul>
<li>网络负载过大，加载的文件太多，首屏加载时间过长</li>
<li>客户端 JS 负载过重，需要在客户端执行太多的 JS 逻辑</li>
</ul>
<p>针对 <strong>首次内容绘制</strong> 优化，可以参考之前的博客。</p>
<p><a href="https://buycoffee.top/blog/tech/first-load-js">first-load-js</a></p>
<p>而在新博客中，首屏中对于 <code>framer-motion</code> 库采用动态导入配合加载骨架图的方式，可以获得还不错的用户体验。</p>
<p><img src="/blog-img/blog-2024/Code_page_(1).png" alt="Code page (1).png"></p>
<p>同时针对首屏出现的图片进行优化，采用 webp 格式来大幅减少图片占用的带宽大小，同时使用模糊图先进行占位，使图片不至于出现闪屏的效果。</p>
<p><img src="/blog-img/blog-2024/Code_page_(2).png" alt="Code page (2).png"></p>
<p>以下是站点初次加载中的效果：</p>
<p><img src="/blog-img/blog-2024/Untitled%209.png" alt="Untitled"></p>
<p>同时，尽可能减少客户端的 js 数量，优先使用服务端渲染，使得初始页面就有足够的信息可以展示，同时在禁用 js 的浏览器上也能有不错的浏览体验。</p>
<p><img src="/blog-img/blog-2024/Untitled%2010.png" alt="Untitled"></p>
<p>最后，尽可能减少页面的 <strong>First Load JS</strong> ,以加快网站初次加载的速度。</p>
<p><img src="/blog-img/blog-2024/Ray.so_code_export.png" alt="Ray.so code export.png"></p>
<p>最后来看看最终的站点 <strong><em>Lighthouse</em></strong> 得分吧。</p>
<p><img src="/blog-img/blog-2024/Untitled%2011.png" alt="Untitled"></p>
<p>看起来好多了。</p>
<h3>UI</h3>
<p>在 UI 设计上，由于对设计方面本人确实是一知半解，因此参考了众多的站点。</p>
<p><a href="https://ui.aceternity.com/">aceternity</a></p>
<p><a href="https://dash.buycoffee.top/">dash</a></p>
<p><a href="https://nelson.co/">nelson</a></p>
<p><a href="https://www.curated.design/">curated</a></p>
<p><a href="https://oguzyagiz.com/">oguzyagiz</a></p>
<p>在经历了几年的博客折腾后，我现在偏向内容为主，UI 设计与排版都倾向简约的风格。</p>
<p>当然也有一些很出众的设计，但是与我对博客的定位不太符合，因此没有参考。</p>
<p><a href="https://ped.ro/">ped</a></p>
<p><a href="https://rauno.me/">rauno</a></p>
<p><a href="https://artemiilebedev.com/">artemiilebedev</a></p>
<p><a href="https://petro.design/">petro</a></p>
<p>在进行 UI 设计与开发时，我倾向通过小步修改，不断去对比，去达到心中预期的目标。</p>
<p>同时我喜欢统一的设计风格，用相同风格的元素去进行表达。</p>
<p><img src="/blog-img/blog-2024/Untitled%2012.png" alt="Untitled"></p>
<p>对需要等待的场景设计一些动效，让用户的等待变得更加有趣。</p>
<p><img src="/blog-img/blog-2024/Untitled%2013.png" alt="Untitled"></p>
<p>在博客内容页面的设计上，尽可能以内容为核心，不构建可能会干扰到阅读体验的控件，同时以移动端为首要目标进行开发，确保在移动端上也能有良好的阅读体验。</p>
<p><img src="/blog-img/blog-2024/Untitled%2014.png" alt="Untitled"></p>
<p>在页面切换动画上，<strong>framer-motion</strong> 动画库是很广泛的选择，但是存在一个小问题：</p>
<ul>
<li>在禁用 js 的浏览器中，初始内容很可能完全无法显示。</li>
</ul>
<p>比如在 Nelson 的博客中，初始文字模糊渐入显示，在禁用 js 的状态下，内容则是完全无法显示。</p>
<p><a href="https://nelson.co/">nelson</a></p>
<p><img src="/blog-img/blog-2024/Untitled%2015.png" alt="Untitled"></p>
<p>虽然目前禁用 js 的情况为少数，但为了极端情况下，用户还是可以对站点内容进行阅读，因此对于页面内容，不应该使用 js 逻辑对其进行渲染。</p>
<p>在 CSS 中有一个较新的 API 可以实现页面切换的动画，并且这个 API 也十分强大，详情可看：</p>
<p><a href="https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API">View Transitions API</a></p>
<p>在 Next.js 中，可以使用 <a href="https://github.com/shuding/next-view-transitions">next-view-transitions</a>库来快速使用这个 API。</p>
<p>通过这个 CSS API ，就可以在不依赖 JS 的情况下实现页面切换动画：</p>
<p><img src="/blog-img/blog-2024/CleanShot_2024-06-20_at_09.42.49.gif" alt="CleanShot 2024-06-20 at 09.42.49.gif"></p>
<p>对于一些不依赖于内容，是在页面中增强用户体验的组件，<strong>framer-motion</strong> 便尤为强大。</p>
<p><img src="/blog-img/blog-2024/CleanShot_2024-06-20_at_09.47.18.gif" alt="CleanShot 2024-06-20 at 09.47.18.gif"></p>
<p>以上就是</p>
<ol>
<li>Local MDX file management - 本地 MDX 文件管理</li>
<li>Performance - 性能</li>
<li>UI - 界面 UI</li>
</ol>
<p>这三点在新博客中的一些思考与应用。</p>
<h2>部署上线</h2>
<p><img src="/blog-img/blog-2024/Untitled%2016.png" alt="Untitled"></p>
<p>从 2021 年的第一个版本的博客开始，我就将博客部署在 Vercel 平台上。</p>
<p>Vercel 有着方便且强大的自动部署能力，而且 Vercel 的服务稳定性和良好的仪表盘使得用户使用体验非常好。</p>
<p>并且对于个人用户，免费的 Hobby 计划就已足够，还可以免去每月的 VPS 费用。</p>
<p>对于需要加速境内访问，只需在 DNS 解析中将 Vercel 官方的 cname 记录切换为</p>
<p><a href="https://vercel.cdn.yt-blog.top/">https://vercel.cdn.yt-blog.top/</a></p>
<p>即可。</p>
<h2>后语</h2>
<p>希望你也可以在这个博客站点中感受到代码的乐趣和各种有趣的小巧思。</p>
<p>我们 2025 年 Blog Refresh 再见 👋。</p>]]></description><link>https://buycoffee.top/blog/tech/blog-2024</link><guid isPermaLink="true">https://buycoffee.top/blog/tech/blog-2024</guid><dc:creator><![CDATA[Hamster1963]]></dc:creator><pubDate>Thu, 20 Jun 2024 00:00:00 GMT</pubDate><enclosure url="https://buycoffee.top/blog-img/blog-2024/cover.jpeg" length="0" type="image/jpeg"/></item><item><title><![CDATA[实现 ChatGPT 负载均衡]]></title><description><![CDATA[<h2>前言</h2>
<p>目前chatGPT无疑仍然是目前市面上众多生成式AI模型中的领头羊，不仅在生成速度与质量上都十分出色，而且也有着很完整的开发文档，开发者可以很方便的使用sdk直接调用api进行更多不同场景业务的实现。</p>
<p>但是目前对于国内的用户却不太友好，不仅在网络环境上由于GFW先被卡了一关，OpenAI对帐号的严格风控也使得顺利使用困难重重，好在网上各路神仙大显神通，可以以极低的价格购买一批API keys进行早期的程序测试，在程序完成后再采用正式的key进行上线。</p>
<p><img src="/blog-img/chatgpt-go/Untitled.png" alt="Untitled"></p>
<h2>免费key的问题</h2>
<p>但是对于免费key来说，有两个问题比较致命，一是最多只有5美金的试用额度，如果不分配均匀很容易会侧重消耗某个key的余额，另外便是每分钟的请求速率只有每分钟3条的限制。</p>
<h2>部署后端的问题</h2>
<p>因客观原因，后端服务只能部署在国内云服务器上，因此墙内访问openAI的 api 接口也是不小的挑战。</p>
<h2>需要解决的问题</h2>
<ol>
<li>多 key 的动态分配调用，在外部调用层只需修改 base_url 而无需进行更多的配置</li>
<li>后端部署在国内服务器上，需要解决访问 api 的问题。</li>
</ol>
<h2>国内服务器访问 api</h2>
<p>在查找资料后，选择了阿里云的云函数进行 api 的转发。</p>
<p><a href="https://github.com/Ice-Hazymoon/openai-scf-proxy/blob/master/README-aliyun.md"></a></p>
<p>在搭建完成后，只需在代码中指定 api 的地址为阿里云的云函数地址即可。</p>
<p><img src="/blog-img/chatgpt-go/Untitled%201.png" alt="Untitled"></p>
<pre><code class="language-go">// Completion
//
//	@dc: 代理chat/completion
//	@params:
//	@response:
//	@author: Hamster   @date:2023/7/5 15:05:00
func (u *uProxyChat) Completion(authToken, model string, chatMessage []openai.ChatCompletionMessage) (resp *openai.ChatCompletionResponse, err error) {
	// 获取配置base_url
	baseConfig, err := service.GptProxyConfig().GetConfig(context.Background(), &#x26;m_gpt_proxy_config.InSelectByWhere{ConfigKey: "OPEN_AI_BASE_URL"})
	if err != nil {
		fmt.Printf("GetConfig error: %v\n", err)
		return resp, err
	}
	config := openai.DefaultConfig(authToken)
	config.BaseURL = baseConfig.ConfigValue
	client := openai.NewClientWithConfig(config)
	clientResp, err := client.CreateChatCompletion(
		context.Background(),
		openai.ChatCompletionRequest{
			Model:    model,
			Messages: chatMessage,
		},
	)
	if err != nil {
		return resp, err
	}
	resp = &#x26;clientResp
	return resp, nil
}
</code></pre>
<p>实测云函数地区选择新加坡🇸🇬，延迟在 500ms 之内，与直接海外服务器访问延迟相差不大。</p>
<h2>负载均衡算法</h2>
<p>核心思想是分为 步：</p>
<ol>
<li>从数据库/缓存中获取全部可用的 key</li>
<li>从 key 列表中获取使用次数最少的一个 key</li>
<li>尝试使用 key 发起 api 请求</li>
</ol>
<p>（请求成功）记录key 当前分钟剩余可用请求次数与可用字符</p>
<p>（请求失败）进行重试逻辑判断</p>
<h3>重试算法</h3>
<p>采用通道阻塞与计时器的方式进行重试的尝试，重试后如果仍不成功则慢慢加大每次重试的间隔时间，最大程度减少 panic 与错误传递给调用方的可能。</p>
<pre><code class="language-go">// PerformWithRetry
//
//	@dc: 重试等待机制
//	@auth: Hamster   @date:2023/7/8 15:10:46
func (u *uLoadBalance) PerformWithRetry(ctx context.Context, operation func(params GPTParams) (GPTResp *openai.ChatCompletionResponse, err error), config GPTRetryConfig, params GPTParams) (GPTResp *openai.ChatCompletionResponse, err error) {
	retries := 0
	for {
		GPTResp, err := operation(params)
		if err == nil {
			return GPTResp, nil
		}
		select {
		case &#x3C;-ctx.Done():
			return nil, errors.New("操作被取消")
		default:
			gmlock.Unlock(u.key)
			retries++
			if retries >= config.MaxRetries {
				return nil, &#x26;GPTRetryError{
					Err:        err,
					Retries:    retries,
					MaxRetries: config.MaxRetries,
				}
			}

			backoff := config.BackoffStrategy(retries)
			glog.Warningf(ctx, "操作失败，重试中... (重试次数：%d/%d, 重试间隔：%v)", retries, config.MaxRetries, backoff)
			select {
			case &#x3C;-ctx.Done():
				return nil, errors.New("操作被取消")
			case &#x3C;-time.After(backoff):
			}
		}
	}
}
</code></pre>
<h3>定义对应结构体数据结构</h3>
<pre><code class="language-go">type GPTRetryConfig struct {
	MaxRetries      int
	RetryInterval   time.Duration
	BackoffStrategy func(retry int) time.Duration
}

type GPTRetryError struct {
	Err        error
	Retries    int
	MaxRetries int
}

type GPTParams struct {
	AuthToken   string
	Model       string
	ChatMessage []openai.ChatCompletionMessage
}

// KeyCacheInfo 缓存中的key信息
type KeyCacheInfo struct {
	KeyValue    string // key值
	RequestLeft int    // 1分钟内key剩余请求次数
	TokenLeft   int    // 1分钟内key剩余token数
}
</code></pre>
<h2>最终运行效果</h2>
<p><img src="/blog-img/chatgpt-go/WechatIMG36.jpg" alt="WechatIMG36.jpg"></p>
<p>在日志中可以清晰的看到每一个 key 的具体使用情况，同时可以看到重置限制时间的倒计时。</p>
<h2>完整代码</h2>
<pre><code class="language-go">// GPTCoreOperation
//
//	@dc: 代理chat/completion
//	@author: Hamster   @date:2023/7/5 15:05:00
func (u *uLoadBalance) GPTCoreOperation(params GPTParams) (GPTResp *openai.ChatCompletionResponse, err error) {
	var (
		ctx             = context.TODO()
		cacheKeysStruct []*entity.GptProxyKey
		cacheKeyInfo    = &#x26;KeyCacheInfo{}
	)

	// 1.从数据库中获取全部key
	cacheKeysStruct, _ = service.GptProxyKey().RetrieveKey(ctx, &#x26;m_gpt_proxy_key.InSelectByWhere{KeyStatus: 1})
	gmlock.Lock(u.key) // 加锁
	// 2.获取使用次数最小的key
	minUsedKey := &#x26;entity.GptProxyKey{}
	for _, cacheKey := range cacheKeysStruct {
		// 获取是否可用
		singleCacheKeyVar := gcache.MustGet(ctx, cacheKey.KeyValue)
		if !singleCacheKeyVar.IsNil() {
			singleCacheKeyInfo := &#x26;KeyCacheInfo{}
			err := singleCacheKeyVar.Struct(&#x26;singleCacheKeyInfo)
			if err != nil {
				continue
			}
			glog.Debug(ctx, "当前key: ", cacheKey.KeyValue, " 剩余请求次数: ", singleCacheKeyInfo.RequestLeft,
				" 剩余token数: ", singleCacheKeyInfo.TokenLeft, " 重置时间: ", gcache.MustGetExpire(ctx, cacheKey.KeyValue))
			if singleCacheKeyInfo.RequestLeft &#x3C;= 0 || singleCacheKeyInfo.TokenLeft &#x3C;= 0 {
				continue
			}
		}
		if minUsedKey.KeyValue == "" {
			minUsedKey = cacheKey
		} else {
			if cacheKey.UsedCount &#x3C; minUsedKey.UsedCount {
				minUsedKey = cacheKey
			}
		}
	}
	if minUsedKey.KeyValue == "" {
		return nil, errors.New("没有可用的key")
	}
	glog.Info(ctx, "当前使用次数最小的key: ", minUsedKey.KeyValue)
	// 3.检测Key是否在缓存中且可用
	cacheKeyVar := gcache.MustGetOrSetFuncLock(ctx, minUsedKey.KeyValue, func(ctx context.Context) (interface{}, error) {
		initCacheInfo := &#x26;KeyCacheInfo{
			KeyValue:    minUsedKey.KeyValue,
			RequestLeft: 3,
			TokenLeft:   40000,
		}
		return initCacheInfo, nil
	}, 1*time.Minute)

	err = cacheKeyVar.Struct(&#x26;cacheKeyInfo)
	if err != nil {
		return nil, err
	}

	// 4.如果可用，使用该key进行操作(减少request_left与token_left)
	cacheKeyInfo.RequestLeft--

	//TODO (laixin) 2023/7/7: 修改token_left
	err = gcache.Set(ctx, cacheKeyInfo.KeyValue, cacheKeyInfo, gcache.MustGetExpire(ctx, cacheKeyInfo.KeyValue))
	if err != nil {
		return nil, err
	}
	gmlock.Unlock(u.key) // 解锁

	// 5.修改key的使用次数

	_ = grpool.AddWithRecover(ctx, func(ctx context.Context) {
		err = service.GptProxyKey().UpdateKey(ctx, &#x26;m_gpt_proxy_key.InUpdate{
			Id:         minUsedKey.Id,
			UsedCount:  minUsedKey.UsedCount + 1,
			UpdateTime: gtime.Now(),
		})
		if err != nil {
			glog.Warning(ctx, "修改key使用次数失败: ", err.Error())
		}
	}, nil)

	// 6.调用GPT接口
	gptStartTime := gtime.Now()
	GPTResp, err = gpt_utils.ProxyChat.Completion(minUsedKey.KeyValue, params.Model, params.ChatMessage)
	gptEndTime := gtime.Now()

	// 7.如果失败,修改key失败次数
	if err != nil {
		glog.Warning(ctx, "调用GPT接口失败: ", err.Error())
		_ = grpool.AddWithRecover(ctx, func(ctx context.Context) {
			// 记录日志
			err = service.GptProxyLog().AddGPTLog(ctx, &#x26;m_gpt_proxy_log.InAdd{
				ApiPath:        "/chat/completions",
				RequestParams:  gconv.String(params),
				ResponseResult: "{}",
				ResponseTime:   gptEndTime.Sub(gptStartTime).Seconds(),
				ErrorMessage:   err.Error(),
				KeyId:          gconv.Int(minUsedKey.Id),
				CreateTime:     gtime.Now(),
			})
			if err != nil {
				glog.Error(ctx, "记录日志失败: ", err.Error())
			}
			// 修改key失败次数
			err = service.GptProxyKey().UpdateKey(ctx, &#x26;m_gpt_proxy_key.InUpdate{
				Id:          minUsedKey.Id,
				FailedCount: minUsedKey.FailedCount + 1,
				UpdateTime:  gtime.Now(),
			})
			if err != nil {
				glog.Error(ctx, "修改key失败次数失败: ", err.Error())
			}
		}, nil)
		return nil, err
	}

	// 8.如果成功,修改key成功次数，返回结果
	_ = grpool.AddWithRecover(ctx, func(ctx context.Context) {
		// 记录日志
		err = service.GptProxyLog().AddGPTLog(ctx, &#x26;m_gpt_proxy_log.InAdd{
			ApiPath:        "/chat/completions",
			RequestParams:  gconv.String(params),
			ResponseResult: gconv.String(GPTResp),
			ResponseTime:   gptEndTime.Sub(gptStartTime).Seconds(),
			ErrorMessage:   "",
			KeyId:          gconv.Int(minUsedKey.Id),
			CreateTime:     gtime.Now(),
		})
		if err != nil {
			glog.Warning(ctx, "记录日志失败: ", err.Error())
		}
		// 修改key成功次数
		err = service.GptProxyKey().UpdateKey(ctx, &#x26;m_gpt_proxy_key.InUpdate{
			Id:           minUsedKey.Id,
			SuccessCount: minUsedKey.SuccessCount + 1,
			UpdateTime:   gtime.Now(),
		})
		if err != nil {
			glog.Warning(ctx, "修改key成功次数失败: ", err.Error())
		}

	}, nil)
	return GPTResp, nil
}
</code></pre>
<h2>后话</h2>
<p>在这套系统上线后，在 GPT 模型的前期验证中剩下了一大笔开销，也减少了调用方的编写各种重试逻辑的难度。</p>]]></description><link>https://buycoffee.top/blog/tech/chatgpt-go</link><guid isPermaLink="true">https://buycoffee.top/blog/tech/chatgpt-go</guid><dc:creator><![CDATA[Hamster1963]]></dc:creator><pubDate>Fri, 04 Aug 2023 00:00:00 GMT</pubDate><enclosure url="https://buycoffee.top/blog-img/chatgpt-go/cover.jpeg" length="0" type="image/jpeg"/></item><item><title><![CDATA[博客里的实时指针]]></title><description><![CDATA[<h2>介绍</h2>
<p>你可以已经注意到，在你的指针下，跟随着一个属于你的指针指示器，并且如果当前页面正在有其他访客浏览的话，你也会看到他们的指针指示器，并且还可以按下<code>/</code>键与他们进行交流。</p>
<p><img src="/blog-img/cursor/CleanShot_2024-09-10_at_16.32.202x.png" alt="CleanShot 2024-09-10 at 16.32.20@2x.png"></p>
<h2>灵感来源</h2>
<p>上周，在部署 Vercel 项目时，发现 Next.js Conf 2024 已经开始了各种宣传。</p>
<p>Next.js Conf 也是由 Vercel 进行举办的，每年 Next.js Conf 都会在宣传页上有些设计或者交互上的小心思。</p>
<video width="100%" class="rounded-xl" autoplay loop muted playsinline>
  <source src="/blog-img/cursor/SaveTwitter.Net_DWNmDlfQqbm8S1jr_(1080p).mp4" type="video/mp4">
</video>
<p>今年的宣传页采用协作游戏的形式，大家通过鼠标移动下方的方块到指定的范围中，当全部的方块都摆放正确后，就会出现蓝色的 Next logo。</p>
<p>在这个过程中，可以看到其他人的指针以及可以与他人进行实时交流的方式让我眼前一亮。</p>
<p>实现背后的技术原理当然是 Websocket，在这个页面中，Vercel 采用了</p>
<p><a href="https://liveblocks.io/">Liveblocks | Build collaborative experiences faster</a></p>
<p>作为背后的中间件。</p>
<p>在 Liveblocks 中也有对应的Live Cursor 示例可供参考。</p>
<p><img src="/blog-img/cursor/CleanShot_2024-08-30_at_15.50.542x.png" alt="CleanShot 2024-08-30 at 15.50.54@2x.png"></p>
<p><a href="https://liveblocks.io/examples/live-cursors-chat/nextjs-live-cursors-chat">Live Cursors Chat | Liveblocks Example</a></p>
<p>虽然在 Next.js 中引入 Liveblocks 便可以很方便地构建实时指针，但为何不直接使用 Websocket 去实现呢? 因此便开始了自建同步服务的折腾过程。</p>
<h2>自建同步服务</h2>
<p>Websocket 双向通信的特性使得同步信息的难度大幅降低，因此只需要定义好<strong>消息类型</strong>以及<strong>消息结构</strong>便可以快速构建起同步服务。</p>
<p><img src="/blog-img/cursor/Frame_14.png" alt="alt: websocket 架构"></p>
<h3>客户端（浏览器）</h3>
<p>在浏览器中，对指针的移动（mousemove）进行监听，获取指针的位置传递给服务器。</p>
<p>在同步位置给服务器时，采用节流函数的方式，在一段时间内只同步一次位置给服务器，以降低网络的负载压力。</p>
<p><img src="/blog-img/cursor/ray-so-export-2.png" alt="ray-so-export-2.png"></p>
<p>同时，在接收到广播的其他指针信息后，与本地的指针进行合并进行渲染。</p>
<p>在客户端的处理逻辑是比较简单的，最核心的在于如何在整个应用程序中采用同一个 Websocket 链接， 在不同的组件中可以独立处理消息的接收解析与发送。</p>
<p>因此借助 <strong>Context Hook</strong> 用于在不同的组件中访问 Websocket 消息接收与发送。</p>
<p><img src="/blog-img/cursor/ray-so-export.png" alt="alt: 构建 WebsocketProvider"></p>
<p>在顶层导入后，就可以在组件中方便地使用。</p>
<pre><code class="language-tsx">const { message, sendMessage } = useWebSocketContext()
</code></pre>
<p>客户端的参考代码如下：</p>
<p><a href="https://gist.github.com/hamster1963/3b53ed604b5aeabbfb7c26c182a2ab4f">Live Cursor</a></p>
<h3>指针动画</h3>
<p>在谈及服务端之前，让我们先来聊聊如何使得实时指针看起来更灵动一些。</p>
<p>首先，搭配 framer-motion 的 <strong>AnimatePresence</strong>，指针的出现和消失，都带有轻微的消除动画（blur + opacity），使得不会有突兀的视觉表现。</p>
<video width="100%" class="rounded-xl" autoplay loop muted playsinline>
  <source src="/blog-img/cursor/CleanShot_2024-09-10_at_17.46.20.mp4" type="video/mp4">
</video>
<p>在指针的位置移动上，使用</p>
<pre><code class="language-tsx">transition={{ type: 'spring', damping: 20 }}
</code></pre>
<p>来让移动带有一些弹性，不会太过于生硬。</p>
<video width="100%" class="rounded-xl" autoplay loop muted playsinline>
  <source src="/blog-img/cursor/CleanShot_2024-09-10_at_17.50.42.mp4" type="video/mp4">
</video>
<p>最后，为了在不同页面尺寸上，指针都能有大致准确的位置显示，采用相对于屏幕比例的方式来显示其他的指针，这样便可以使得在不同的屏幕上都有一致的位置显示。</p>
<pre><code class="language-tsx">;(pointer.x / pointer.screenWidth) * window.innerWidth - window.scrollX
</code></pre>
<h2>服务端</h2>
<p>在服务端的实现上，核心是数据更新与广播。</p>
<p>在接收到客户端传来的数据后，通过 type 来区分不同的数据处理方式。</p>
<p><img src="/blog-img/cursor/ray-so-export-2%201.png" alt="ray-so-export-2.png"></p>
<p>在数据处理中，首先定义好不同的数据结构体，将房间内的指针都放到同一个哈希表中，后续都是针对这个房间内的 CursorList 进行操作与广播。</p>
<pre><code class="language-go">type RoomData struct {
	RoomId     string
	Clients    []*Client
	CursorList CursorList
	Mu         sync.Mutex
}

type Cursor struct {
	Id           string `json:"id"`
	X            int    `json:"x"`
	Y            int    `json:"y"`
	Name         string `json:"name"`
	Path         string `json:"path"`
	ScreenWidth  int    `json:"screenWidth"`
	ScreenHeight int    `json:"screenHeight"`
}

type CursorText struct {
	Id   string `json:"id"`
	Text string `json:"text"`
}

type Client struct {
	Ws   *ghttp.WebSocket
	Id   string
	Name string
}

type CursorList map[string]*Cursor
</code></pre>
<p>在接收到客户端传来的指针数据后，获取数据内的 id，修改指针哈希表中对应的数据，并将更新后的数据在房间内广播，即完成了指针数据在不同客户端之间的同步。</p>
<pre><code class="language-go">func (rm *RoomManager) UpdateCursorList(cursorData *Cursor, roomId string) {
	room, err := rm.GetRoom(roomId)
	if err != nil {
		return
	}

	room.Mu.Lock()
	room.CursorList[cursorData.Id] = cursorData
	room.Mu.Unlock()

	for _, c := range room.Clients {
		if c.Id == cursorData.Id {
			continue
		}
		rm.SyncCursorList(c, roomId)
	}
}
</code></pre>
<h2>参考资料</h2>
<p>在构建的过程中，翻阅了许多这方面的资料，不仅有 Liveblocks 的方案，也有来自 Supabase 的 Realtime 中展示的 Demo。</p>
<p><a href="https://supabase.com/blog/supabase-realtime-with-multiplayer-features">Supabase Realtime, with Multiplayer Features</a></p>
<p>还有在 Hacker News 上看到的类似的站点，</p>
<p><a href="https://interconnected.org/home/2024/09/05/cursor-party">Every webpage deserves to be a place</a></p>]]></description><link>https://buycoffee.top/blog/tech/cursor</link><guid isPermaLink="true">https://buycoffee.top/blog/tech/cursor</guid><dc:creator><![CDATA[Hamster1963]]></dc:creator><pubDate>Thu, 12 Sep 2024 00:00:00 GMT</pubDate><enclosure url="https://buycoffee.top/blog-img/cursor/cover.jpeg" length="0" type="image/jpeg"/></item><item><title><![CDATA[DDNSTO]]></title><description><![CDATA[<blockquote>
<p>DDNSTO有付费与免费两种模式选择，免费套餐需要每隔7天手动进行一次续期。</p>
</blockquote>
<h2>前言</h2>
<p>openwrt是目前众多路由器刷机的选择，界面美观而且插件丰富，但是有一定的上手成本，远程管理路由器在openwrt上的配置并不简单，ddnsto的出现大大降低了新手的使用成本，使用体验也十分友好。</p>
<h2>使用教程</h2>
<callout emoji="💡">
  小提示:本插件理论上适用于任何基于openwrt的原版或者第三方路由器,本文使用LEDE固件进行演示。
</callout>
<h3>安装DDNSTO插件</h3>
<p>从网站中<a href="https://firmware.koolshare.cn/binary/ddnsto/openwrt/">下载</a>对应架构版本的ipk文件</p>
<p>在openwrt路由器后台管理界面中安装插件</p>
<p>使用文件传输功能将.ipk文件上传至/tmp/upload/
<img src="/blog-img/ddnsto/VJBywGW35vdjlsU.png" alt="截屏2021-04-05 下午10.13.25.png"></p>
<p>上传成功后跳转至软件包页面</p>
<p><img src="/blog-img/ddnsto/C58FTYhkneIKyUv.png" alt="截屏2021-04-05 下午10.15.02.png"></p>
<p>在下载并安装软件包输入框中输入/tmp/upload/对应的名字.ipk等待安装完成</p>
<p>在服务中看到DDNS.to内网穿透一栏就证明安装成功了</p>
<h3>配置DDNSTO</h3>
<p>打开<a href="https://www.ddnsto.com%E7%82%B9%E5%87%BB%E5%8F%B3%E4%B8%8A%E8%A7%92%E7%99%BB%E5%BD%95%E5%90%8E%E5%8F%B0%E5%B9%B6%E4%BD%BF%E7%94%A8%E5%BE%AE%E4%BF%A1%E6%B3%A8%E5%86%8C">https://www.ddnsto.com点击右上角登录后台并使用微信注册</a></p>
<p>复制页面上的令牌<img src="/blog-img/ddnsto/1E967LeiVQHPIRw.png" alt="image-20210405222125549.png"></p>
<p>回到路由器页面打开DDNS.to插件页面在令牌框内将复制的令牌粘贴并勾选启用，点击页面的保存&#x26;应用</p>
<p><img src="/blog-img/ddnsto/opkN9r28lDgy7nm.png" alt="截屏2021-04-05 下午10.23.04.png"></p>
<p>稍等片刻回到DDNSTO后台，你的路由器应该就会出现在页面中。</p>
<p>点击添加域名映射，输入你喜欢的名字并将路由器的内网地址填入并保存。</p>
<h2>使用DDNSTO</h2>
<p>一切配置完成后应该如图一样</p>
<p><img src="/blog-img/ddnsto/3TXy4CAibBw8doF.png" alt="WX20210405-222616@2x.png"></p>
<p>点击给出的域名，如果可以正确跳转至路由器管理页面，恭喜你已经可以正常使用啰！SEE YOU NEXT TIME！</p>]]></description><link>https://buycoffee.top/blog/tech/ddnsto</link><guid isPermaLink="true">https://buycoffee.top/blog/tech/ddnsto</guid><dc:creator><![CDATA[Hamster1963]]></dc:creator><pubDate>Mon, 05 Apr 2021 00:00:00 GMT</pubDate><enclosure url="https://buycoffee.top/blog-img/ddnsto/cover.jpeg" length="0" type="image/jpeg"/></item><item><title><![CDATA[M1 Face Recognition]]></title><description><![CDATA[<h1>前言</h1>
<p>在搭载M1芯片的mac上顺利进行开发是一件不仅随缘而且需要人品的事情，不仅仅因为M1芯片的ARM架构所带来的编译方式的不同，bugsur新的文件路径系统以及时不时的抽风也使得顺利开发的路上艰难险阻。今天就来看看GitHub上大名鼎鼎的开源人脸库face_recognition如何比较顺利地在M1上跑起来且实现简单的实时人脸识别的功能。</p>
<callout emoji="⚠️">
  由于不可描述的原因，网络环境所造成的安装失败不在本文讨论范围中。
</callout>
<h1>第一部分 关于环境的那点事</h1>
<h2>网络环境搭建</h2>
<p><callout emoji="ℹ️️">建议进行设置省去不必要的麻烦</callout></p>
<p>大部分的环境搭建与库安装需要使用终端进行，由于终端默认并不走代理的网络，为了此后下载的顺利，先需要设置终端也可以使用代理。</p>
<p>首先，在当前用户根目录新建一个文件名为 .bash_profile 的空白文本，然后输入以下代码：</p>
<pre><code class="language-md">function proxy_off(){
unset http_proxy
unset https_proxy
unset ftp_proxy
unset rsync_proxy
echo -e "已关闭代理"
}

function proxy_on() {
export no_proxy="localhost,127.0.0.1,localaddress,.localdomain.com"
export http_proxy="https://127.0.0.1:1087"
export https_proxy=$http_proxy
        export ftp_proxy=$http_proxy
export rsync_proxy=$http_proxy
        export HTTP_PROXY=$http_proxy
export HTTPS_PROXY=$http_proxy
        export FTP_PROXY=$http_proxy
export RSYNC_PROXY=$http_proxy
echo -e "已开启代理"
}
</code></pre>
<p>在<strong>export no_proxy</strong>与<strong>export http_proxy</strong>填入对应的本地代理服务器地址以及端口，可以在你的代理软件中查看。</p>
<p><img src="/blog-img/face-recognition/DQGUuVM5hx6asg2.png" alt="截屏2021-03-31 上午12.42.42.png"></p>
<p>将文件配置好并放在用户根目录后</p>
<h3>当让终端走代理的时候，输入：</h3>
<pre><code>source  ~/.bash_profile
proxy_on
</code></pre>
<h3>想关闭代理的时候，输入：</h3>
<pre><code>proxy_off
</code></pre>
<h2>基本环境搭建</h2>
<h3>安装python最新版本</h3>
<p>从python官网下载最新版本 python 记得选中 <a href="https://www.python.org/downloads/mac-osx/">universal</a></p>
<p>安装完成后在终端输入</p>
<pre><code>open ~/.bash_profile
</code></pre>
<p>在文件中加上一行环境变量配置后保存退出</p>
<pre><code>export PATH=/usr/local/bin:$PATH
</code></pre>
<p>重新打开终端输入</p>
<pre><code>source ~/.bash_profile
python3 --version     -------注意是python3
</code></pre>
<p>顺利的话你会在终端中看到python的版本号信息，python安装到此结束。</p>
<h3>各种依赖包安装</h3>
<callout emoji="👋️">
  可以试试直接安装 ,设置好终端网络代理后
  <br>
  在终端中输入 pip3 install face_recognition
</callout>
<proscard title="在安装face_recognition之前我们需要安装:" pros="{[" &#x27;homebrew&#x27;,="" &#x27;miniforge&#x27;,="" &#x27;numpy&#x27;,="" &#x27;openblas&#x27;,="" &#x27;opencv&#x27;,="" &#x27;cmake&#x27;,="" &#x27;dlib&#x27;,="" ]}="">
<h3>安装homebrew</h3>
<p>下载安装过程略</p>
<p>安装完成后根据提示在终端中输入</p>
<pre><code>open ~/ .zprofile
</code></pre>
<p>在文件中加入</p>
<pre><code>eval "$(/opt/homebrew/bin/brew shellenv)"
</code></pre>
<p>保存后重启homebrew，在终端中输入</p>
<pre><code>brew
</code></pre>
<p>如果出现各种指令提示，证明安装成功</p>
<h3>安装miniforge</h3>
<p>在网站中选择Apple Silicon版本下载，下载后直接运行即可</p>
<p><img src="/blog-img/face-recognition/JFu2coavtEZB8LC.png" alt="demo.png"></p>
<p>接下来我们需要在终端中激活</p>
<pre><code>conda create -n myenv python=3.9 //创建名为myenv，python版本为3.9的环境
conda activate myenv //激活环境
</code></pre>
<h3>安装numpy</h3>
<p>激活conda且配置好网络代理后可以直接在终端中输入</p>
<pre><code>pip3 install numpy=1.9.14
</code></pre>
<p>等待下载安装，安装完成后检测是否安装成功，在终端中输入</p>
<pre><code class="language-python">python3
>>>import numpy
</code></pre>
<p>没报错的话就安装成功了</p>
<h3>安装openblas</h3>
<p>激活conda且配置好网络代理后可以直接在终端中输入</p>
<pre><code>pip3 install openblas
</code></pre>
<p>等待下载安装，安装完成后检测是否安装成功，在终端中输入</p>
<pre><code class="language-python">python3
>>>import openblas
</code></pre>
<p>没报错安装成功</p>
<h3>需要编译的库</h3>
<p>&#x3C;ProsCard title="" pros={['opencv', 'cmake', 'dlib']} /></p>
<h3>安装opencv</h3>
<p>点击链接下载opencv库，仅适用于M1芯片</p>
<p><a href="https://github.com/wizyoung/AppleSiliconSelfBuilds/blob/main/builds/opencv_contrib_python-4.5.0+bbaa777-cp39-cp39-macosx_11_0_arm64.whl">opencv_contrib_python-4.5.0+bbaa777-cp39-cp39-macosx_11_0_arm64.whl</a></p>
<p>在终端中激活环境</p>
<pre><code>conda activate myenv //激活环境
</code></pre>
<p>定位到下载目录并安装</p>
<pre><code>cd 目录
pip3 install opencv_contrib_python-4.5.0+bbaa777-cp39-cp39-macosx_11_0_arm64.whl
</code></pre>
<p>等待安装即可</p>
<h3>安装cmake</h3>
<p>终端配置好网络代理后输入</p>
<pre><code>brew install cmake //安装cmake
</code></pre>
<h3>安装Dlib</h3>
<p>激活conda且配置好网络代理后直接在终端中输入</p>
<pre><code>pip3 install dlib
</code></pre>
<p>安装后在终端输入</p>
<pre><code>python3
>>>import dlib
</code></pre>
<p>如果未报错恭喜你安装成功，我们就差最后一步了。</p>
<h2>安装face_recognition</h2>
<p>激活conda且配置好网络代理后直接在终端中输入</p>
<pre><code class="language-python">pip3 install face_recognition
</code></pre>
<p>安装完成后在终端中输入</p>
<pre><code class="language-python">python3
>>>import face_recognition
</code></pre>
<p>如果没有报错证明万里长征我们已经走了一半了。</p>
<p>也可以使用 pip3 list 命令来查看所有已经安装的库 如图</p>
<p><img src="/blog-img/face-recognition/uCx1dKmAkaMZ46l.png" alt="截屏2021-03-31 上午1.05.28.png"></p>
<p>到此安装告一段落。</p>
<h1>第二部分 开始造轮子吧</h1>
<p>由于安装这些依赖和库可能已经损耗你的大部分精力了，</p>
<p>所以接下来我们就快速地来看看怎么把这个简单的实时人脸识别跑起来。</p>
<p><callout emoji="⚠️">摄像头是必须的，所以请不要关上盖子运行接下来的步骤</callout></p>
<h3>下载py文件</h3>
<p>从GitHub上下载</p>
<p><a href="https://github.com/ageitgey/face_recognition/blob/master/examples/facerec_from_webcam_faster.py">facerec_from_webcam_faster.py</a></p>
<h3>使用编辑器编辑</h3>
<p>用你喜欢的编辑器打开，这里使用vscode示范，下面代码都是从源文件中截出，对应修改即可。</p>
<pre><code class="language-python">
# Load a sample picture and learn how to recognize it.

图片人名1_image = face_recognition.load_image_file("把这里改成你要识别的图片的路径")

图片人名1_face_encoding = face_recognition.face_encodings(图片人名1_image)[0]



# Load a second sample picture and learn how to recognize it.

图片人名2_image = face_recognition.load_image_file("把这里改成你要识别的图片2的路径")

图片人名2_face_encoding = face_recognition.face_encodings(图片人名1_image)[0]

# Create arrays of known face encodings and their names
known_face_encodings = [
    图片人名1_face_encoding,
    图片人名2_face_encoding
]
known_face_names = [
    "图片1所对应的全名",
    "图片2所对应的全名"
]


</code></pre>
<h3>例子</h3>
<p>如图，图片1为Obama，图片2为biden</p>
<p><img src="/blog-img/face-recognition/8GY3PlShE6OQdJ9.png" alt="WX20210328-195510@2x.png"></p>
<h3>运行一下吧！</h3>
<p>打开终端，输入</p>
<pre><code class="language-python">python3 &#x3C;将文件拖到这里或者粘贴文件的路径>
</code></pre>
<p>就像这样</p>
<p><img src="/blog-img/face-recognition/jW6kPmswvN4nrXK.png" alt="WX20210328-195510@2x.png"></p>
<p>按下回车等待奇迹的发生</p>
<p>不出意外的话，掏出你的小手机拿出一张照片或者把你的脸对准摄像头，就可以看到</p>
<p><img src="/blog-img/face-recognition/NHzceP3gfYtynl5.png" alt="Demo"></p>
<h2>大功告成啦！</h2>
<h1>尾巴</h1>
<p>总体来说跑起这个小项目还是基本没有难度的，最主要的是有稳定的网络以及足够的耐心</p>
<p>face_recognition这个库具有很多方向的应用，今天只是简单地入门一下，还有很多玩法可以发掘哦！</p></proscard>]]></description><link>https://buycoffee.top/blog/tech/face-recognition</link><guid isPermaLink="true">https://buycoffee.top/blog/tech/face-recognition</guid><dc:creator><![CDATA[Hamster1963]]></dc:creator><pubDate>Sun, 16 May 2021 00:00:00 GMT</pubDate><enclosure url="https://buycoffee.top/blog-img/face-recognition/cover.jpeg" length="0" type="image/jpeg"/></item><item><title><![CDATA[优化 First Load JS]]></title><description><![CDATA[<p>在前端页面中，性能指标是常常被提及的话题，目前的指标通常由以下几个数据组成：</p>
<ol>
<li>服务器响应时间：用户访问网站后需要多久时间才会获得响应。</li>
<li>页面渲染时间：页面完全可见以及可交互所需要的时间。</li>
<li>用户交互时间：用户在页面上进行关键交互的所需时间。（比如添加商品至购物车）</li>
</ol>
<p>更详细的介绍可以参考以下两篇 Vercel 官方撰写的博客。</p>
<p><a href="https://vercel.com/blog/guide-to-fast-websites-with-next-js-tips-for-maximizing-server-speeds">Guide to fast websites with Next.js: Tips for maximizing server speeds and minimizing client burden – Vercel</a></p>
<p><a href="https://vercel.com/docs/speed-insights">Speed Insights Overview</a></p>
<h2>目标优化站点</h2>
<p>通常在页面开发过程中，我们可以采用<a href="https://pagespeed.web.dev/">https://pagespeed.web.dev/</a>或 Chrome 浏览器中自带的 Lighthouse 来进行页面性能的简单测试。</p>
<p>这是我们未优化的页面：</p>
<p><img src="/blog-img/first-load-js/Untitled.png" alt="Untitled"></p>
<p>可以看到目前站点首屏的 Lighthouse 性能测试中，<strong>LCP(Largest Contentful Paint)</strong> 与 <strong>Speed Index</strong> 两项指标的表现并不是很好。</p>
<p>在进行全部的优化之前，先让我们看一下构建的日志结果。</p>
<p><img src="/blog-img/first-load-js/Untitled%201.png" alt="Untitled"></p>
<p>不难看出，在构建结果中，首屏的页面大小（Size）与首次需要加载的 JS 大小（First Load JS）相对于其他的页面突出不少，而页面大小与 First Load JS 与 <strong>LCP(Largest Contentful Paint)</strong> 的分数息息相关。因此我们先对首屏的 JS 大小进行分析。</p>
<h2>分析首屏 JS</h2>
<p>站点使用 Next.js 进行构建，可以通过 <code>@next/bundle-analyzer</code> 包来进行 JS 捆绑包的分析。</p>
<pre><code class="language-bash">bun i @next/bundle-analyzer
</code></pre>
<p>在项目根目录下的 <code>next.config.mjs</code> 中进行分析的配置。</p>
<pre><code class="language-jsx">import withBundleAnalyzer from '@next/bundle-analyzer'

const bundleAnalyzer = withBundleAnalyzer({
  enabled: process.env.ANALYZE === 'true',
})

/** @type {import('next').NextConfig} */
const nextConfig = {
  output: 'export',
  experimental: {
    optimisticClientCache: true,
    swcMinify: true,
    gzipSize: true,
    taint: true,
  },
}
export default bundleAnalyzer(nextConfig)
</code></pre>
<p>配置完成后，通过设置环境变量<code>ANALYZE</code>进行使用 bundle-analyzer 分析的构建:</p>
<pre><code class="language-bash">ANALYZE=true bun run build
</code></pre>
<p>在构建过程中，浏览器会自动打开三个页面。</p>
<ol>
<li>nodejs.html 显示服务器端的所有捆绑包。</li>
<li>edge.html 显示在边缘计算中的所有捆绑包。</li>
<li>client.html 显示客户端的所有捆绑包。</li>
</ol>
<p><em>在本项目中，由于是采用静态部署，因此通过分析 client.html 即可。</em></p>
<p>首先在看看首页的组件构成：</p>
<p><img src="/blog-img/first-load-js/Untitled%202.png" alt="Untitled"></p>
<p>主要由网络信息图表，订阅列表以及服务器列表构成。</p>
<p>在 client.html 中，筛选出首页：</p>
<p><img src="/blog-img/first-load-js/Untitled%203.png" alt="Untitled"></p>
<p>分析页面上显示的捆绑包，所占的面积越大，表示捆绑包越大。</p>
<p><img src="/blog-img/first-load-js/Untitled%204.png" alt="Untitled"></p>
<p>不难看出，recharts 包所占的空间不小，而此包主要用于网络速率显示图表中：</p>
<p><img src="/blog-img/first-load-js/Untitled%205.png" alt="Untitled"></p>
<p>因此可以通过优化此部分的组件内包导入来优化 First Load JS。</p>
<h2>优化包导入</h2>
<p>在官方的最佳实践中，我们可以采用 <strong>Lazy Loading</strong> 的方式，尽可能延迟那些比较重的依赖包。</p>
<p><a href="https://nextjs.org/docs/app/building-your-application/optimizing/lazy-loading">Optimizing: Lazy Loading</a></p>
<p>动态导入是其中的一种方式，原理十分简单，在初次获取页面时，并不会去获取需要动态导入的依赖包，只有在页面加载后，才会根据条件去进行获取依赖包，这对于大型网站的优化十分有用，比如需要用户点击后才会出现的图表，或者是需要登录后才会显示的一些用户组件，这些都是无需在第一次获取页面时就去下载的数据，因此都可以使用动态导入来进行优化。</p>
<p><img src="/blog-img/first-load-js/Untitled%206.png" alt="Untitled"></p>
<p>那就着手优化，这是优化前的组件导入：</p>
<pre><code class="language-tsx">import NetworkChart from '@/components/NetworkChart'
</code></pre>
<p>将其改成动态导入，并且加上一个骨架进行占位显示：</p>
<pre><code class="language-tsx">import dynamic from 'next/dynamic'
const NetworkChart = dynamic(() => import('@/components/NetworkChart'), {
  ssr: false,
  loading: () => &#x3C;Skeleton className="mt-2 h-[80px] w-full rounded-[10px]" />,
})
</code></pre>
<p>这样子，在页面初次加载的时候，就不用加载 recharts 依赖包，而是等到页面加载后，再进行下载，同时骨架使得页面的 Cumulative Layout Shift 保持较低的水平。</p>
<p><img src="/blog-img/first-load-js/Untitled%207.png" alt="Untitled"></p>
<p>使用动态导入后，重新进行构建：</p>
<p><img src="/blog-img/first-load-js/Untitled%208.png" alt="Untitled"></p>
<p>可以看到，Size 从 115kB → 7.63kB ，同时 FLJ 也从 267kB → 159kb，使用动态导入后优化效果十分显著。</p>
<p>重新进行 Lighthouse 的测试，可以发现 LCP 也从先前的 4.6s 优化到1.6s。</p>
<p><img src="/blog-img/first-load-js/Untitled%209.png" alt="Untitled"></p>
<p>欢迎访问页面来试试首屏显示的速度。</p>
<p><a href="https://dash.buycoffee.top/">HomeDash</a></p>
<h2>结论</h2>
<p>在优化首屏显示中，通过将一些比较重的依赖包采用动态导入的方式进行导入，不仅优化了页面大小也优化了初次加载 JS 大小，使得页面在初次加载的时候速度更快。</p>]]></description><link>https://buycoffee.top/blog/tech/first-load-js</link><guid isPermaLink="true">https://buycoffee.top/blog/tech/first-load-js</guid><dc:creator><![CDATA[Hamster1963]]></dc:creator><pubDate>Thu, 25 Apr 2024 00:00:00 GMT</pubDate><enclosure url="https://buycoffee.top/blog-img/first-load-js/cover.jpeg" length="0" type="image/jpeg"/></item><item><title><![CDATA[AI SDK: From v4 to v6]]></title><description><![CDATA[<h2>Ship AI</h2>
<p>前不久，Vercel 举办了 Ship AI 的 Conf，推出了一些新鲜的想法和库，不仅有引起热烈讨论的 <code>use workflow</code> ，还有 Vercel Agent 等一系列的产品更新，那就借着 Ship AI，一起聊聊最核心的更新发布 - AI SDK 5。</p>
<p><a href="https://vercel.com/ship/ai">Ship AI 2025</a></p>
<h2>AI SDK</h2>
<p>自发布以来，AI SDK 一直都是最热门的构建 AI 应用的框架之一，这个使用 TS 语言编写的框架，与其称之为一个第三方库，我更愿意称之为一套完整却灵活的AI应用构建解决方案。</p>
<p><a href="https://ai-sdk.dev/">AI SDK</a></p>
<p><img src="/blog-img/ai-sdk/CleanShot_2025-11-13_at_17.40.292x.png" alt="CleanShot 2025-11-13 at 17。40。29@2x。png"></p>
<p>但是与 Vercel 公司旗下的核心 React 框架 - <strong>Next.js</strong> 类似，在简单的示例程序背后，需要花费大量时间去学习与构建真正可用且好用的应用程序，AI SDK 同样需要你阅读大量的官方文档，更新日志甚至源码来紧跟最新的“最佳实践”。</p>
<p>AI SDK最大的特色便是UI层与逻辑层的无缝衔接，构建起来的逻辑很顺，构建一个简易的 Chatbox 的流程可以分为三步。</p>
<ul>
<li>在 api 层（或独立后端）使用 AI SDK 定义好模型提供商与模型参数</li>
<li>在 UI 层使用 AI SDK 使用 useChat 获取 api 层流式返回的内容</li>
<li>使用 react-markdown 或其他 markdown 渲染库将 AI 返回的内容渲染到页面上</li>
</ul>
<p>这样子就可以在短时间内搓出一个还不错的 Chatbox。</p>
<p><img src="/blog-img/ai-sdk/CleanShot_2025-11-13_at_17.35.332x.png" alt="alt:一个简易的 Chatbox"></p>
<p>但是构建一个完整的包含用户管理、绘画历史、图片识别等等功能的完整 Chatbox，还是需要在许多方面进行细节的构建，接下来，就跟随我升级我的个人 Chatbox - Chatty 从 v4 升级到 v5 与 v6 的过程，来聊聊如何基于 AI SDK 一步步构建这些功能吧。</p>
<h2>项目介绍</h2>
<p>Chatty 是一个基于 <strong>AI SDK(Next.js)</strong> 与 <strong>Hono</strong> 的 AI 聊天工具，内含图片识别、联网搜索、MCP 等功能，同时以优化渲染性能为核心，为用户带来快速、流畅的使用体验。</p>
<p>首先看看最终构建的成果吧。</p>
<p>正如你看到的一样，Chatty 的基础对话功能和 Chatgpt 完全一致：</p>
<video width="100%" class="rounded-xl" autoplay loop muted playsinline>
  <source src="https://blog-video.takecoffee.top/blog-img/ai-sdk/CleanShot_2025-11-08_at_19.30.52.mp4" type="video/mp4">
</video>
<ul>
<li>用户输入文字按下回车</li>
<li>模型通过流式的方式返回已生成的文本</li>
<li>通过一个简约的渐变动画显示在用户的界面上</li>
</ul>
<p>因此使用 AI SDK 构建与自己直接对接 API 有什么不同呢，接下来我将从：</p>
<ol>
<li>useChat</li>
<li>MCP</li>
<li>Resumable Stream</li>
<li>消息元数据</li>
<li>联网搜索实现</li>
<li>Reasoning</li>
<li>FollowUp</li>
</ol>
<p>这几个方面详细聊聊使用 AI SDK 构建 Chatty 与升级 AI SDK 的过程。</p>
<h2>useChat</h2>
<p><a href="https://ai-sdk.dev/docs/reference/ai-sdk-ui/use-chat">useChat</a> 可以算得上是在 AI SDK UI 层最核心的功能，useChat 主要包含三个功能：</p>
<ul>
<li>流式传输聊天信息</li>
<li>管理聊天状态</li>
<li>收到新消息时自动更新UI</li>
</ul>
<p>一个最基础的示例代码如下：</p>
<p><img src="/blog-img/ai-sdk/ray-so-export.png" alt="alt:简单的 useChat 使用方式"></p>
<p>通过 useChat 导出的这三个方法即可轻松地实现发消息，渲染消息与获取消息状态这三个流程。</p>
<h3>AI SDK 5 的破坏性更改</h3>
<p>在从 V4 升级到 V5 的过程中，useChat 存在不少的 Breaking Changes，而最核心的便是 useChat 的内部共享状态逻辑的改变。</p>
<p>在 V4 中，useChat 可以通过相同的 id 共享 useChat 中的数据，而在 V5 中，我们需要手动来维护这个全局共享，例如用 React Context 的方式来维护 useChat 实例。</p>
<p><img src="/blog-img/ai-sdk/ray-so-export-2.png" alt="alt:V4 版本的 useChat"></p>
<p>使用 React Context 的方式可以参考：</p>
<p><a href="https://gist.github.com/hamster1963/1c43da9b8730ed1f6ff4cdbed0475cda">useSharedChat</a></p>
<p><img src="/blog-img/ai-sdk/ray-so-export-3.png" alt="alt: V5 版本的 useSharedChat"></p>
<p>在使用的时候，通过 useSharedChat 的方式去获取 useChat 实例。</p>
<h3>精简发送消息与发送自定义数据</h3>
<p>在 useChat 中，在使用 sendMessage 时，默认情况下会将当前的全部消息都发送至 API 端口，这在大量上下文的对话中，会十分消耗网络流量与影响速度，除此之外，我们可能还需要在请求中附带许多额外的信息。</p>
<p>因此我们可以通过 <code>prepareSendMessagesRequest</code> 来精简发送信息与自定义发送数据。</p>
<p><img src="/blog-img/ai-sdk/ray-so-export-4.png" alt="alt:使用 prepareSendMessagesRequest 自定义发送内容skip-compression"></p>
<p>我们通过设置最终发送的 message 为最新一条记录（也就是用户发送的消息）来大幅精简请求体的大小，并且在 body 中我们可以轻松设置其他的自定义参数。</p>
<p>在后端，则是通过获取数据库中存储的消息记录后拼接从接口收到的 message 形成了完整的信息。</p>
<p><img src="/blog-img/ai-sdk/ray-so-export-6.png" alt="alt:组装完整信息skip-compression"></p>
<p>那接下来，就从 MCP 的整体构建流程来如何在 AI SDK 中实现工具调用与 UI 的渲染。</p>
<h2>MCP</h2>
<blockquote>
<p>MCP（模型上下文协议）是一个开源标准，用于连接人工智能应用程序到外部系统。</p>
</blockquote>
<p>通过 <a href="https://modelcontextprotocol.io/docs/getting-started/intro">MCP</a> 协议，我们可以快速定义与构建一组工具供模型去使用，可以大幅扩展模型获取外部数据的能力，帮助模型获取解决问题所需的核心资料来更好地进行回复。</p>
<p>同时，模型也可以通过工具调用直接去实现一些基础的操作，这也是构建 Agent 的核心，关于构建 Agent 的话题我们以后再聊，目前让我们专注于如何构建 MCP 服务与 AI SDK 的流程衔接。</p>
<h3>使用 <strong>FastMCP 构建 MCP 服务器</strong></h3>
<p>在 MCP 服务的构建上，本次选择了 <a href="https://github.com/jlowin/fastmcp">FastMCP</a> 框架作为后端进行项目的构建。</p>
<blockquote>
<p>FastMCP 是一个基于 Python 的 MCP 服务端框架，可以将一组工具快速转化为一个完整的 MCP 服务，让 MCP 客户端可以通过标准协议（SSE、Streamable HTTP）连接到服务并调用定义好的工具。</p>
</blockquote>
<p><img src="/blog-img/ai-sdk/ray-so-export-7.png" alt="alt:一个最简单的 FastMCP 服务端示例"></p>
<h3>在 AI SDK 中接入 MCP</h3>
<p>在 AI SDK 中， MCP 中的工具等同于传统的 tools ，因此整体的接入流程为:</p>
<ul>
<li>创建 MCP 客户端</li>
<li>将 MCP 客户端连接至 MCP 服务端</li>
<li>获取可用的工具列表</li>
<li>将工具列表传递至 AI</li>
</ul>
<p><img src="/blog-img/ai-sdk/ray-so-export%201.png" alt="alt:连接MCP服务端并获取可用工具列表"></p>
<p>首先我们通过 StreamableHTTPClientTransport (MCP官方库)来创建一个使用 StreamableHTTP 连接方式的 MCP 客户端。</p>
<p>创建完成后将其传递至 AI SDK 的 <code>experimental_createMCPClient</code>，这样子便可以通过 <code>.tools</code> 方式去获取 MCP 服务端中已经注册的工具列表。</p>
<p>最后，将获取到的工具作为 <code>streamText</code> 的 <code>tools</code> 参数，便完成了接入 MCP 的整个流程。</p>
<p><img src="/blog-img/ai-sdk/ray-so-export%202.png" alt="alt:传入工具与请求后关闭连接"></p>
<h3>在 UI 层显示工具调用</h3>
<p>在用户对话交互界面展示AI选择并调用了哪些工具是很重要的， 用户不仅可以清晰地了解到模型调用了什么工具，还可以了解到调用工具所用的参数与工具返回的数据。</p>
<video width="100%" class="rounded-xl" autoplay loop muted playsinline>
  <source src="https://blog-video.takecoffee.top/blog-img/ai-sdk/CleanShot_2025-11-23_at_23.13.48.mp4" type="video/mp4">
</video>
<p>AI SDK对于工具调用相关的数据主要分为两类:</p>
<ol>
<li>在 AI SDK 服务端预定义好的工具调用数据</li>
<li>通过 MCP 或其他方式动态获取的动态工具调用数据</li>
</ol>
<p>解析类型为 <code>dynamic-tool</code> 的消息块作为MCP工具调用的数据来源， 可以通过解析 <code>part.toolName</code> 与 <code>part.state</code> 来获取调用的工具名称与调用状态。</p>
<p><img src="/blog-img/ai-sdk/ray-so-export%203.png" alt="alt:筛选出工具调用相关数据"></p>
<p>为了更加含义地显示调用工具的信息，可以通过在本地维护一份对应表来显示一些特定工具的调用过程的名称，例如读取页面与网络搜索。</p>
<p><img src="/blog-img/ai-sdk/ray-so-export-2%201.png" alt="alt:工具名称与中文名称对应表"></p>
<p>同时，用户可以通过点击工具信息来查看更加详细的入出参数信息与工具状态信息，这在AI模型调用工具的结果不尽人意时，可以帮助用户了解工具调用的流程以来优化提示词的编写。</p>
<p><img src="/blog-img/ai-sdk/CleanShot_2025-11-30_at_15.16.142x.png" alt="alt:点击工具以显示详细调用信息"></p>
<h3>在聊天上下文中精简工具相关数据</h3>
<p>在经过长时间的对话后，工具调用累计的数据可以会极大地占据了上下文空间，因此可以通过 AI SDK 提供的<code>pruneMessages</code> 来精简上下文，使用的方式也很简单:</p>
<pre><code class="language-tsx">let prunedMessages = pruneMessages({
  messages: convertToModelMessages(originMessages)，
  reasoning: "all"，
  toolCalls: "before-last-2-messages"，
  emptyMessages: "remove"，
});
</code></pre>
<p>精简的核心在于 <code>toolCalls</code> 参数，我们可以通过 <code>before-last-${number}-messages</code> 的方式来决定需要在上下文中保留多少个工具调用的完整数据，通常保留最后 2 个工具调用数据就可以大幅精简上下文且不会对 AI 的回复质量有大的影响。</p>
<h2>Resumable Stream</h2>
<p>在与 AI 对话的过程中，常常会出现这两种情况</p>
<ol>
<li>用户发送完消息后在等待的过程中关闭了页面后重新打开</li>
<li>在另一个设备中打开了相同的对话</li>
</ol>
<p>这时就需要一种机制来还原流式传输的数据，在 AI SDK 中这被称为 Resumable Stream。</p>
<video width="100%" class="rounded-xl" autoplay loop muted playsinline>
  <source src="https://blog-video.takecoffee.top/blog-img/ai-sdk/CleanShot_2025-11-30_at_15.57.00.mp4" type="video/mp4">
</video>
<p>通过 Resumable Stream，可以在多设备或是断线重连的情况下重新同步流式传输的数据，而无需等待消息生成完成。</p>
<p>配置 Resumable Stream 的方式也很简单，只需要一个可用的 Redis 数据库即可。</p>
<p>要实现 Resumable Stream 主要分为三个步骤:</p>
<ul>
<li>新增一个 <code>/stream</code> API端点来实现消息流的重新获取</li>
<li>在 <code>/api/chat</code> 实现通过 <code>streamContext</code> 存储流信息</li>
<li>在客户端使用 <code>resumeStream</code> 来恢复流</li>
</ul>
<h3>创建上下文</h3>
<p><code>resumable-stream</code> 是vercel 推出的一个用于可恢复流的工具库，通过 redis 存储流信息搭配 <strong>sub/pub</strong> 的方式来实现消息流的同步与恢复。</p>
<p><img src="/blog-img/ai-sdk/ray-so-export-3%201.png" alt="alt:构建可恢复流上下文管理skip-compression"></p>
<p>这便是一个完整的流上下文所需要的方法，特别需要注意的是，如果是部署在 serverless 的环境中，例如 vercel， cloudflare 时，需要设置一个 <code>waitUntil</code> 参数来保证在消息流为传输状态时，无状态的函数会一直等待到消息流完成后才会销毁，这可以避免由于函数的提前销毁而导致上下文丢失的问题。</p>
<p>在创建 <code>/stream</code> API端口时，我们需要用到对话 ID 来查询是否存在正在进行中的对话流并进行恢复，而在没有可用流时则返回一个空的流式返回结果。</p>
<p><img src="/blog-img/ai-sdk/ray-so-export-4%201.png" alt="alt:/stream端口skip-compression"></p>
<p>而在AI对话流程中使用到的 <code>/api/chat</code> ，则是在返回流式数据前通过 streamID 将消息流的标识信息写入到上下文中(存储在 Redis 中)。</p>
<p>这看似会对 Redis 造成不小的读写压力，但是实际上，真正的流式数据存储在内存中， Redis 只是存储消息流的元数据与状态，真正的数据传输是通过 <strong>sub/pub</strong> 的方式进行的。</p>
<p><img src="/blog-img/ai-sdk/ray-so-export-5.png" alt="alt:将流写入到下上文"></p>
<h3>在客户端获取可恢复流</h3>
<p>在客户端，通过 <code>useSharedChat</code> 中的 <code>resumeStream</code> 方式，便可以让客户端通过先前我们创建的 <code>/stream</code> API端口获取到可恢复流。</p>
<p><img src="/blog-img/ai-sdk/ray-so-export-6%201.png" alt="alt:在客户端处理流恢复"></p>
<p>通过依靠 sessionId 的变化来进行可恢复流的获取，以实现多设备同步与断线重连的效果。</p>
<p>接下来，让我们讨论一下如何在消息中存储自定义元数据。</p>
<h2>消息元数据</h2>
<p>在构建AI对话时，我们也会想尽可能地保留每一条消息的相关数据，比较基础的例如:</p>
<ul>
<li>输入的 token 数 - <strong>prompt_tokens</strong></li>
<li>输出的 token 数 - <strong>completion_tokens</strong></li>
<li>思考过程使用的token 数 - <strong>reasoning_tokens</strong></li>
<li>模型ID - <strong>model</strong></li>
</ul>
<p>这些数据往往会记录在模型消息的输出中，除此之外，我们常常还想要存储一些自定义的数据，例如：</p>
<ul>
<li>
<p>首 token 的等待时间</p>
</li>
<li>
<p>总输出时间</p>
</li>
<li>
<p>模型输出 token 速度</p>
<p><img src="/blog-img/ai-sdk/CleanShot_2025-12-01_at_12.51.212x.png" alt="CleanShot 2025-12-01 at 12。51。21@2x。png"></p>
</li>
</ul>
<p>这些数据与每一条 AI 回复的信息进行关联，帮助用户了解模型或者对话的信息。</p>
<h3>临时元数据与永久元数据</h3>
<p>在 AI SDK 中，消息的附带数据分为临时与永久两种，主要的区别与用法为:</p>
<ul>
<li>临时数据 - 用来表示当前的处理状态，例如: <em><strong>正在精简上下文</strong> <strong>正在生成搜索关键词 已完成搜索</strong></em> 这些状态在完成后即被丢弃而无需进行存储，因此被称为临时数据。</li>
<li>永久数据 - 上文中提到的需要一直显示或记录在消息中的数据，便于用户查看与了解对话流程的数据。</li>
</ul>
<p>在 AI SDK 中， 可以通过使用 <strong>UIMessageStreamWriter</strong> 将这些元数据写入到消息中。</p>
<p>其中 <code>transient</code> 表示此为临时还是永久元数据。</p>
<p><img src="/blog-img/ai-sdk/ray-so-export-2%202.png" alt="alt:通过 transient 参数控制是否需要存储"></p>
<p>在客户端中，可以通过 <code>onData</code> 回调来特别处理临时元数据的展示。</p>
<p><img src="/blog-img/ai-sdk/ray-so-export-3%202.png" alt="alt:在客户端获取临时消息元数据"></p>
<p>接下来，让我们一起讨论一下目前比较常见的联网搜索功能如何在 AI SDK 中实现。</p>
<h3>P.S. 消息token的自定义计算</h3>
<p>在某些模型提供商的返回中，可能并不会返回上文中所提及的消息 token 数信息，因此在这种情况下，可以通过 <code>gpt-tokenizer</code> 这个库去粗略计算一下。</p>
<pre><code class="language-tsx">import { encode } from "gpt-tokenizer";

export function estimateTokens(text: string): number {
  return encode(text)。length;
}
</code></pre>
<h2>两种联网搜索的实现方式</h2>
<p>由于大模型的特性， 大模型真正在训练中得到的是“<strong>能力</strong>”而非“<strong>记忆</strong>”， 因此在一些较为小众的问题或超出知识时限的问题上， 大模型可能会出现幻觉从而回答并不存在的内容或无法正确回复， 因此如何让大模型获取外部知识便是一个很好的话题。</p>
<p>在众多的方式中， 给予大模型联网搜索的能力是应用得最多的方式之一， 而如何给予大模型这种能力在目前看来有两种较为通用的方法:</p>
<ul>
<li>分析用户输入的内容 → 生成搜索关键词 → 进行搜索 → 将结果放置在系统提示词</li>
<li>在 MCP 中定义联网搜索工具 → AI 调用工具 → 进行搜索 → AI 获取到工具返回的搜索结果</li>
</ul>
<p>这两种实现方式适合于不同的使用场景，那就先从第一个实现方式开始说起吧。</p>
<h2>被动的联网搜索</h2>
<p>对于一些没有工具调用能力的模型，或者在工具调用方面较差的模型，被动的联网搜索可以在不使用工具调用的情况下给予模型足够的上下文进行回复。</p>
<p>接下来就一步一步介绍一下如何实现这种方式。</p>
<h3>分析用户输入内容并生成提示词</h3>
<p>在这种模式下，可以通过给用户提供一个联网搜索的选项按钮，来区分用户的联网搜索意图。</p>
<video width="100%" class="rounded-xl" autoplay loop muted playsinline>
  <source src="https://blog-video.takecoffee.top/blog-img/ai-sdk/CleanShot_2025-11-30_at_16.59.28.mp4" type="video/mp4">
</video>
<p>在接收到用户的输入内容后，可以通过一些小模型针对上下文进行分析，进而生成一个较为符合上下文的搜索词。</p>
<p><img src="/blog-img/ai-sdk/ray-so-export-7%201.png" alt="alt:使用 GPT 5-mini 生成搜索关键词"></p>
<p>直接通过 <code>generateText</code> 与提示词搭配的方式来生成搜索词，而如果获取到当前的用户意图并不需要搜索的，则返回 <code>no search</code> 来表示无需进行后续的搜索。</p>
<h3>编写联网搜索流程</h3>
<p>在获取到搜索词后，就有很多种方式去进行联网的数据检索，常见的联网搜索方式有:</p>
<ul>
<li>自建的开源搜索服务: <strong>SearXNG</strong></li>
<li>免费的搜索服务: <strong>duckduckgo-api</strong></li>
<li>付费的搜索服务提供商: <strong>linkup</strong> <strong>tavily</strong></li>
</ul>
<p>本次就以 <a href="https://www.linkup.so/"><strong>linkup</strong></a> 为例进行搜索流程的构建。</p>
<p><strong>linkup</strong> 在免费计划中包含了 5€/月的免费额度， 对于基础的查询，每次花费大约为 <strong>0.005€ - 0.05€</strong>，因此在个人轻度使用下，这个额度还是绰绰有余的。</p>
<p><img src="/blog-img/ai-sdk/CleanShot_2025-12-01_at_12.37.372x.png" alt="alt:linkup的调用费用"></p>
<p>同时 linkup 支持多种输出格式与搜索深度设置，可以很轻松地使用输出的数据进行自定义的展示与进一步的处理。</p>
<p>在上面的处理中，我们已经成功地获取到了根据上下文而获取的搜索提示词，接下来就可以直接通过<code>linkup-sdk</code> 中的<code>LinkupClient</code> 进行联网检索了。</p>
<p><img src="/blog-img/ai-sdk/ray-so-export%204.png" alt="alt:linkup的调用流程"></p>
<p>如上图，我们设置搜索深度为标准，同时在搜索结果中包含相关的图片信息，以构建我们的自定义搜索结果UI。</p>
<h3>写入元数据与构建联网搜索结果提示词</h3>
<p>在获取到 linkup 返回的搜索结果后，下一步则是将数据处理成三个不同的数据块。</p>
<ul>
<li>AI模型推理所需的包含搜索结果的系统提示词</li>
<li>返回给用户并绑定在消息元数据上的搜索网页结果列表</li>
<li>返回给用户并绑定在消息元数据上的图片列表</li>
</ul>
<p>让我们先简要说说如何将搜索网页结果列表与图片列表绑定在消息元数据。</p>
<p>在获取到搜索结果后，提取搜索结果中我们所需的</p>
<ul>
<li>站点名称</li>
<li>站点内容</li>
<li>通过站点地址获取的 icon 地址</li>
</ul>
<p>将这些数据记录为 <code>searchAnnotation</code> ，定义类型为 <code>search_results</code> 。</p>
<p>最后，通过writer 将 <code>searchAnnotation</code> 流式写入到本次的AI回复消息中。</p>
<p>💡 <em><strong>特别注意的是，在调用写入的时候，如果设置了 <code>transient: true</code> 则表示数据为临时信息，不会写入到消息元数据中，因此在这里可以直接忽略这个参数的设置。</strong></em></p>
<p><img src="/blog-img/ai-sdk/ray-so-export-2%203.png" alt="alt:将搜索结果写入到消息元数据中"></p>
<p>接下来便是构建一个提示词，让模型可以知道搜索的关键词与搜索结果，从而帮助AI推理出更准确的内容。</p>
<p><img src="/blog-img/ai-sdk/ray-so-export-3%203.png" alt="alt:构建完整联网搜索提示词"></p>
<p>上图便是一个简单却十分有效的提示词，对于目前 <strong>2025 年底</strong> 所有的大模型来说，这已经提供了足够的数据与指引，模型可以基于这些信息进行正确的推理与输出。</p>
<p>提示词中包含：</p>
<ul>
<li>当前的时间</li>
<li>用户的搜索词</li>
<li>返回的搜索结果</li>
</ul>
<p>以帮助模型进行推理回答。</p>
<h3>构建搜索状态展示</h3>
<p>对于模型来说，在推理前只需要等待联网搜索流程执行即可，但是对于用户来说，这可能是一个持续一段时间的过程，因此我们可以通过上文中提及的临时元数据展示当前的处理流程，以优化用户的使用体验。</p>
<video width="100%" class="rounded-xl" autoplay loop muted playsinline>
  <source src="https://blog-video.takecoffee.top/blog-img/ai-sdk/CleanShot_2025-12-09_at_13.07.04_2.mp4" type="video/mp4">
</video>
<p>对于用户而言，可以清晰的看到当前的联网搜索执行状态</p>
<ul>
<li>正在提取搜索关键词</li>
<li>正在搜索</li>
<li>搜索完成</li>
</ul>
<p>在服务端，只需要在执行节点前后使用 <code>write</code> 将状态写入到临时元数据中即可。</p>
<p><img src="/blog-img/ai-sdk/ray-so-export-2%204.png" alt="alt:发送流程节点提示"></p>
<p>在用户端，则是通过 <code>onData</code> 回调来进行当前执行状态的获取与展示。</p>
<p><img src="/blog-img/ai-sdk/ray-so-export%205.png" alt="alt:客户端获取当前节点流程"></p>
<h3>构建搜索结果UI</h3>
<p>对于用户来说，了解模型是基于哪些内容进行回答也是十分重要的，因此在联网搜索场景，可以在模型回复的上方展示推理所用到的搜索结果以及相关的图片。</p>
<p><img src="/blog-img/ai-sdk/CleanShot_2025-12-09_at_13.14.082x.png" alt="alt:搜索结果UI"></p>
<p>而其中的数据便来自于上面步骤中写入到消息中需要保留的元数据，而我们现在重点关注一下客户端如何获取并提取出这些数据。</p>
<blockquote>
<p>如果你使用 Next.js 完成整个 ChatBox 的构建，AI SDK的类型推断会帮助你轻松构建类型安全的消息元数据写入与获取，而目前这种前后端分离的架构则需要手动进行类型定义。</p>
</blockquote>
<p>在 AI SDK 中，每个消息内部都是由一个个的块（<strong>parts</strong>）构成的，因此我们需要手动筛选出搜索结果与相关图片的 <code>parts</code> ，在筛选后即可进行后续的数据提取与展示。</p>
<p><img src="/blog-img/ai-sdk/ray-so-export-3%204.png" alt="alt:筛选出搜索结果相关数据"></p>
<p>对于搜索结果与图片，我们需要提取去在服务端定义的两种类型，也就是服务端进行 <code>write</code> 时定义的 <code>type</code> 参数，提取后将数据传入组件进行平铺展示。</p>
<h2>主动联网搜索</h2>
<p>随着模型的能力越来越强，模型的工具调用能力也在一直提升，因此主动联网搜索便是将搜索这个操作的主动权交给模型。</p>
<p>这个主动权的交接则是通过自定义工具或者 MCP 的方式直接将工具调用这个能力提供给模型，模型通过选定方法与入参进行调用，以达到联网搜索或者更多功能的效果。</p>
<p>这种方式有两种好处：</p>
<ul>
<li>无需自己编写联网搜索流程，只需提供联网搜索相关工具即可</li>
<li>模型可以自主进行多次搜索，直到获取到模型推理所需的数据</li>
</ul>
<p><img src="/blog-img/ai-sdk/CleanShot_2025-12-10_at_01.49.522x.png" alt="alt:自主进行多次搜索的调用过程"></p>
<h3>构建搜索工具</h3>
<p>在构建联网搜索工具时，在最精简的情况下只需要三个工具：</p>
<ul>
<li>使用关键词进行联网搜索</li>
<li>获取网页信息</li>
<li>获取代码相关文档</li>
</ul>
<p>幸运的是这三种工具都可以很轻松地通过调用三方服务的方式去实现。</p>
<p>对于<strong>联网搜索</strong>，除了上文中提到的 <strong>linkup</strong> 以外，<strong>Tavily</strong> 也是一个不错的选择。</p>
<p><img src="/blog-img/ai-sdk/ray-so-export%206.png" alt="alt:基于Tavily的联网搜索方法"></p>
<p>在 <strong>FastMCP</strong> 中，只需定义好方法名，入参与类型，再加上必须的方法注释，即可在模型获取MCP工具列表时自动转换为 MCP 的工具信息，因此在调用三方 SDK 的场景下编写工具是十分方便的。</p>
<p><img src="/blog-img/ai-sdk/ray-so-export-2%205.png" alt="alt:基于Tavily的页面内容获取方法"></p>
<p>相同的，针对网页内容的获取，使用 <strong>Tavily</strong> 提供的 <code>extract</code> 方法即可。</p>
<p>使用 FastMCP 框架时，一个非常好用的功能则是你可以将其他的 MCP 服务桥接进来，也就是可以直接将其他 MCP 服务提供的工具提供给客户端。</p>
<p>对于获取代码文档这个工具，便可以桥接 <a href="https://exa.ai">exa</a> 公司提供的 <a href="https://mcp.exa.ai/mcp">MCP</a> 服务以使用其中的 <code>get_code_context_exa</code> 工具来获取代码相关文档与上下文。</p>
<p><img src="/blog-img/ai-sdk/ray-so-export-3%205.png" alt="alt:通过代理使用 exa 的MCP服务"></p>
<p>在构建好这三个工具后，在 AI SDK 就可以获取到这些工具并交给模型进行使用。</p>
<h3>P.S. 限制模型最大工具调用次数</h3>
<p>在联网搜索时，有的时候模型可能会陷入无止尽的检索中，因此除了在提示词中限制模型的行为，AI SDK 中提供的 <code>stopWhen</code> 参数可以使我们手动停止模型的工具调用流程。</p>
<p><img src="/blog-img/ai-sdk/ray-so-export-4%202.png" alt="alt:通过 stopWhen 参数来避免模型进行无穷的工具调用"></p>
<p>最简单的方式则是使用 <code>stepCountIs</code> 方法，通过限制模型的最大工具调用次数，来进行最后的兜底，避免模型进行无止尽的搜索以造成巨大的 tokens 开销。</p>
<h2>Reasoning</h2>
<blockquote>
<p>thinking…thinking…thinking…</p>
</blockquote>
<p><strong>深度思考模型</strong> 在2025年可谓是大模型的发展风潮，深度思考模型的本质是通过启用思维链（Chain of Thought）机制，让模型在回答问题前进行深层次的分析和推理，以提高在复杂问题中回答的准确性与可靠性。</p>
<p>而从年初 DeepSeek 发布的 R1 模型开始，后续越来越多的模型开始将模型深度思考过程中的内容暴露给用户。</p>
<p>在 AI SDK 中，思考过程中产生的文本会被归类到 <em><strong>Reasoning</strong></em> 这一类型下，接下来就来聊聊如何在 AI SDK 中对接思考模型与构建思考过程UI。</p>
<h3>提取思考内容</h3>
<p>在 AI SDK 中，有许多的不同AI模型提供商的 providers，一些是官方的，也有一些是社区进行维护的。</p>
<p>简而言之这些 providers 的作用便是降低开发者与不同AI模型提供商之间的对接难度，只需在调用模型时指定使用哪一个 provider，内部便会自动完成 API 与返回格式的转换，让我们只专注核心的模型输出。</p>
<p><a href="https://ai-sdk.dev/providers/ai-sdk-providers">providers</a></p>
<p>在 DeepSeek 的官方 API 返回中，思考内容记录在 <strong>reasoning</strong> 或者 <strong>reasoning_content</strong> 中，在这种情况下，可以直接使用 <a href="https://ai-sdk.dev/providers/ai-sdk-providers/deepseek#deepseek-provider"><strong>DeepSeek Provider</strong></a>，API 返回的思考内容会自动解析到 AI SDK 的 reasoning 出参中。</p>
<p>而对于一些第三方的模型提供商或者自建的 vllm 服务，可能并不像 DeepSeek 的官方API的返回格式，最常见的便是如 groq 返回的数据，思考内容被包裹在 <code>&#x3C;think/>&#x3C;think/></code> XML标签中，需要我们自行进行内容的解析与提取。</p>
<p>在 AI SDK 中，我们可以通过处理中间件来轻松实现这一点。</p>
<p><img src="/blog-img/ai-sdk/ray-so-export-6%202.png" alt="alt:通过中间件进行思考内容的提取"></p>
<p><a href="https://ai-sdk.dev/docs/reference/ai-sdk-core/extract-reasoning-middleware#extractreasoningmiddleware">extractReasoningMiddleware</a></p>
<p>通过使用 <code>extractReasoningMiddleware</code> 这个中间件，我们可以自定义在模型输出内容中提取思考内容的规则，例如上图中，<code>tagName</code> 等同于 <code>&#x3C;think></code> 的标签名，同时我们可以设置 <code>startWithReasoning</code> 来告诉中间件<em>模型一定会先返回思考内容</em>，以便一开始就可以进行解析。</p>
<h3>设置思考参数</h3>
<p>对于 R1 模型来说，思考是一个不可关闭的过程，每次的推理输出中必定都是先输出思考的内容，完成思考内容的输出后再输出回复的内容。</p>
<p>而对于像 Gemini 2.5 或者是 GPT 5 这样的模型来说，模型的思考不仅仅是可以关闭的，同时也可以对模型的思考能力进行控制，在这种情况下，我们就需要在 AI SDK 模型配置中进行相应的配置以发挥模型的推理能力。</p>
<p><img src="/blog-img/ai-sdk/ray-so-export-8.png" alt="alt:设置 GPT 5 模型的推理参数"></p>
<p>以 GPT 5 为例，我们可以通过 OpenAIResponsesProviderOptions 来获取模型的可配置项，对于 GPT 5，我们可以通过：</p>
<ul>
<li>reasoningEffort 推理等级（low medium high）</li>
<li>reasoningSummary 推理总结（因为 openai 不直接返回推理内容）</li>
</ul>
<p>来配置模型思考相关的参数。</p>
<h3>发送思考内容</h3>
<p>在 AI SDK 中，思考内容默认是不输出给客户端的，这时可以通过配置 <code>sendReasoning</code> 参数来控制是否发送思考内容至客户端。</p>
<p><img src="/blog-img/ai-sdk/ray-so-export-9.png" alt="alt:通过sendReasoning参数控制输出中是否包含思考内容"></p>
<h2>构建思考过程UI</h2>
<p>在客户端中，我们通过筛选出类型为 <strong>reasoning</strong> 的 part 来获取思考内容。</p>
<p><img src="/blog-img/ai-sdk/ray-so-export-10.png" alt="alt:从parts筛选出思考内容"></p>
<p>在获取到思考内容后，便可以通过一个单独的组件将其展示出来。</p>
<video width="100%" class="rounded-xl" autoplay loop muted playsinline>
  <source src="https://blog-video.takecoffee.top/blog-img/ai-sdk/ScreenRecording_12-08-2025_00-00-53_1-2.mp4" type="video/mp4">
</video>
<p>如视频中所展示的效果，思考内容不断流式地显示出来，在超过一定的高度后，会进行滚动，同时在上下两侧会有一个淡淡的遮罩层以提醒用户文本正在滚动中。</p>
<p>这样的设计主要基于两个想法：</p>
<ol>
<li>用户并不真的关心模型的思考内容，只需要知道模型正在思考就够了。</li>
<li>思考内容的滚动会让用户在等待真正的回复时的愉悦感好一些。</li>
</ol>
<p>到目前为止，从用户输入文本到模型输出的全流程的构建就完成了！</p>
<p>那下一步是什么呢？</p>
<p>那便是如何让用户可以轻松继续进行下一次的对话。</p>
<h2>FollowUp</h2>
<p><img src="/blog-img/ai-sdk/CleanShot_2025-12-10_at_16.10.492x.png" alt="alt:follow-up"></p>
<p>在 AI 回复完成后，在下方展示 3-5 条对话建议的实用性是非常大的！</p>
<p>在我个人的使用体验中，往往是在这些 follow-up 中找到了灵感。</p>
<h3>生成 follow-up</h3>
<p>我们可以基于模型的最后一次回复来生成 follow-up，当然你可以可以通过总结完整的上下文来获取更加准确的 follow-up，但目前以实际的体验来说，依靠 AI 的<strong>最后一次回复</strong>已经可以生成比较准确的建议。</p>
<p><img src="/blog-img/ai-sdk/ray-so-export-12.png" alt="alt:通过最后一个模型回复生成 follow-up"></p>
<p>如上图，首先在 <code>modelMessages</code> 中过滤出AI回复的内容，并将其作为上下文放入到消息列表中，在提示词中定义好所需的 follow-up 的格式或其他要求即可。</p>
<h3>流式传输 follow-up</h3>
<p>相同的，生成完整的包含多条的 follow-up 可能也需要一定的时间，因此我们可以借助消息元数据，通过<strong>一个相同的 ID</strong> ，不断更新元数据中 follow-up 的部分以达到流式传输 follow-up 的效果。</p>
<p><img src="/blog-img/ai-sdk/ray-so-export-13.png" alt="alt:流式更新 follow-up 列表skip-compression"></p>
<p>在获取到 follow-up 生成的文本流后，主要的流程为：</p>
<ul>
<li>生成一个固定的 ID 以便我们后续进行 follow-up 更新</li>
<li>进行必要的字符过滤与列表拆分</li>
<li>将列表格式的 follow-up 更新到消息元数据中</li>
</ul>
<p>在客户端中，通过类型即可筛选出 follow-up 并进行显示。</p>
<p><img src="/blog-img/ai-sdk/ray-so-export-14.png" alt="alt:在 parts 中筛选出 follow-up 列表"></p>
<h2>两个体验上的优化</h2>
<p>现在我们便有了一个不错的 AI Chat，我们还可以如何优化呢？</p>
<h3>默认监听键盘输入</h3>
<p>💡 感谢 Grok 网页端提供的灵感</p>
<p>在打开各类的 AI Chat 时，许多情况下我们只是急匆匆地想要询问一个简短的问题。</p>
<p>在移动端上，点击输入框即可开始输入，但是在桌面端上想要输入则需要：</p>
<ul>
<li>将鼠标移动至输入框</li>
<li>点击输入框</li>
<li>将焦点锁定在输入框</li>
</ul>
<p>在这一连串的操作后我们才真正地开始文本的输入，因此我们可以通过监听键盘输入来改善这一点。</p>
<p>也就是，用户打开页面时，只要按下了键盘的键位，便会自动将焦点锁定在输入框。</p>
<video width="100%" class="rounded-xl" autoplay loop muted playsinline>
  <source src="https://blog-video.takecoffee.top/blog-img/ai-sdk/CleanShot_2025-12-10_at_16.49.34.mp4" type="video/mp4">
</video>
<p>参考代码如下：</p>
<pre><code class="language-tsx">// Add global keyboard event listener for auto-focus
  useEffect(() => {
    const handleGlobalKeyDown = (event: KeyboardEvent) => {
      // Only focus if:
      // 1. The event is not from the textarea itself
      // 2. No modifier keys are pressed
      // 3. It's a printable character or backspace/delete
      // 4. No input/textarea is currently focused
      // 5. The textarea reference exists
      const target = event。target as HTMLElement
      const isFromTextarea = target === textareaRef。current
      const isModifierPressed = event.ctrlKey || event.metaKey || event.altKey
      const isPrintableKey =
        event.key.length === 1 ||
        event.key === 'Backspace' ||
        event.key === 'Delete'
      const isInputFocused =
        target.tagName === 'INPUT' ||
        target.tagName === 'TEXTAREA' ||
        target.contentEditable === 'true'

      if (
        !isFromTextarea &#x26;&#x26;
        !isModifierPressed &#x26;&#x26;
        isPrintableKey &#x26;&#x26;
        !isInputFocused &#x26;&#x26;
        textareaRef.current
      ) {
        textareaRef.current.focus()

        // If it's a printable character， add it to the input
        if (event.key.length === 1) {
          const currentValue = textareaRef.current.value
          const newValue = currentValue + event.key
          textareaRef.current.value = newValue

          // Trigger the onChange event to update the state
          const syntheticEvent = {
            target: textareaRef.current,
            currentTarget: textareaRef.current,
          } as React.ChangeEvent&#x3C;HTMLTextAreaElement>
          handleInputChange(syntheticEvent)

          // Prevent the default behavior to avoid double input
          event.preventDefault()
        }
      }
    }

    document.addEventListener('keydown', handleGlobalKeyDown)

    return () => {
      document.removeEventListener('keydown', handleGlobalKeyDown)
    }
  }, [handleInputChange])
</code></pre>
<h3>将配置项放到二级菜单</h3>
<p>过多的功能与模型配置会让用户感受到焦虑。</p>
<p>因此一个简洁的配置按钮加输入框便是最好的搭配。</p>
<p><img src="/blog-img/ai-sdk/CleanShot_2025-12-10_at_16.55.362x.png" alt="alt:在二级菜单放置模型列表与功能选项"></p>
<h2>总结</h2>
<p>呼！这一路走来可真不容易！</p>
<p>构建一个 AI Chat 绝非只是一个套壳那么简单，想要构建用户体验良好的AI Chat，在这个过程中往往是各种平衡的决策与细节的构建，希望这篇博客对你构建属于自己的 AI Chat 有帮助，下篇博客见👋！</p>]]></description><link>https://buycoffee.top/blog/tech/from-v4-to-v6</link><guid isPermaLink="true">https://buycoffee.top/blog/tech/from-v4-to-v6</guid><dc:creator><![CDATA[Hamster1963]]></dc:creator><pubDate>Thu, 11 Dec 2025 00:00:00 GMT</pubDate><enclosure url="https://buycoffee.top/blog-img/ai-sdk/cover.jpeg" length="0" type="image/jpeg"/></item><item><title><![CDATA[GitHub Actions 的 CI/CD]]></title><description><![CDATA[<h2>Github Actions与CI/CD</h2>
<p>GitHub Actions是GitHub提供的一种自动化工具，可以帮助您在代码仓库中自动执行任务，如构建、测试和部署。CI/CD（持续集成/持续部署）是一种自动化软件开发实践，可以帮助您更快地交付高质量的软件。在本文中，我们将介绍如何使用GitHub Actions为Go项目实现简易CI/CD。</p>
<h2>实现目标</h2>
<p><img src="/blog-img/github-actions/Untitled.png" alt="Untitled"></p>
<p>将Actions主要分为三个部分</p>
<ol>
<li>在代码提交后进行编译检查，检查是否存在代码错误。</li>
<li>在进行git tag操作后，针对tag进行docker镜像的构建与推送。</li>
<li>达到版本号发布时，自动构建多平台可执行二进制文件。</li>
</ol>
<h2>前置准备</h2>
<h3>为Actions开放仓库权限</h3>
<p>在仓库主页，选中<strong>Settings→Actions→General→Workflow permissions</strong>，将权限设置为Read and write permisssions。</p>
<p><img src="/blog-img/github-actions/Untitled%201.png" alt="Untitled"></p>
<h3>获取Docker相关信息</h3>
<p>将信息储存在<strong>Actions secrets and variables</strong>中，分别设置Docker用户名，token与仓库名称。</p>
<p>获取 Docker token 的步骤如下：</p>
<ol>
<li>登录到 Docker Hub 帐户。</li>
<li>点击右上角的头像，选择“Account Settings”。</li>
<li>在左侧菜单中选择“Security”。</li>
<li>滚动到“Access Tokens”部分，然后单击“New Access Token”按钮。</li>
<li>输入访问令牌的描述，选择令牌的有效期，并选择要授予令牌的权限。</li>
<li>单击“Create”按钮以生成令牌。</li>
<li>复制生成的令牌并保存在安全的地方。</li>
</ol>
<p>仓库名称则为 <strong>用户名/仓库名</strong></p>
<h3>进行secrets的设置</h3>
<p>在<strong>Settings→Secrets and variables</strong>中点击<strong>New repository secret</strong>进行新增。</p>
<ol>
<li>DOCKER_USERNAME</li>
<li>DOCKER_ACCESS_TOKEN</li>
<li>DOCKER_IMAGE_NAME</li>
</ol>
<p><img src="/blog-img/github-actions/Untitled%202.png" alt="Untitled"></p>
<h2>设置Actions</h2>
<p>点击仓库内<strong>Actions</strong>模块，点击<strong>New workflow</strong>进行工作流的新增。</p>
<h3>代码编译检查工作流</h3>
<p>将会在检测到push或pull_request后进行代码编译检查。</p>
<pre><code class="language-yaml"># This workflow will build a golang project
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go

name: 代码编译测试

on:
  push:
    branches: ['main']
  pull_request:
    branches: ['main']

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Set up Go
        uses: actions/setup-go@v3
        with:
          go-version: 1.19

      - name: Build
        run: |
          go build -v ./...
          pwd
</code></pre>
<h3>编译构建并推送Docker镜像</h3>
<p>此工作流只会在仓库新增tag时进行。</p>
<pre><code class="language-yaml">git tag v0.0.1
git push --tags
</code></pre>
<p>工作流分为以下几个步骤</p>
<ol>
<li>获取tag作为版本号</li>
<li>根据Dockerfile进行Docker镜像的构建</li>
<li>针对构建完成镜像进行tag打版本号</li>
<li>将镜像推送至Dockerhub</li>
</ol>
<pre><code class="language-yaml">name: Build and push Docker image

on:
  push:
    tags:
      - 'v*'

env:
  IMAGE_NAME: ${{ secrets.DOCKER_IMAGE_NAME }}

jobs:
  build-and-push:
    runs-on: ubuntu-latest

    steps:
      - name: Get version
        id: get_version
        run: echo "VERSION=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_OUTPUT

      - name: Checkout code
        uses: actions/checkout@v3

      - name: Login to Dockerhub
        uses: docker/login-action@v2
        with:
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_ACCESS_TOKEN }}

      - name: Build Docker image
        run: docker build -t $IMAGE_NAME:${{ github.sha }} .

      - name: Tag Docker image
        run: docker tag $IMAGE_NAME:${{ github.sha }} $IMAGE_NAME:${{ steps.get_version.outputs.VERSION }}

      - name: Tag Docker image as latest
        run: docker tag $IMAGE_NAME:${{ github.sha }} $IMAGE_NAME:latest

      - name: Push Docker image
        run: |
          docker push $IMAGE_NAME:${{ steps.get_version.outputs.VERSION }}
          docker push $IMAGE_NAME:latest
</code></pre>
<p>这时我们需要针对项目进行Dockerfile的编写，一下是一个针对Goframe项目的示例。</p>
<ol>
<li>设置golang构建环境，有许多版本可以选择，可以参考<a href="https://hub.docker.com/_/golang">https://hub.docker.com/_/golang</a>，这里选择<strong>golang:1.20-buster。</strong></li>
<li>拷贝代码至构建环境，进行构建，在这里将编译完成后将二进制文件命名为service，构建命令也可以指定系统与架构，在命令中加上<strong>GOOS=linux GOARCH=amd64</strong>。</li>
<li>设置镜像环境，设置工作路径，为二进制文件添加权限并增加端口绑定。</li>
<li>设置入口命令。</li>
</ol>
<p>至此一份Dockerfile就编写完成了。</p>
<pre><code class="language-docker">FROM golang:1.20-buster AS builder

ARG VERSION=dev

WORKDIR /go/src/app
COPY . .
RUN CGO_ENABLED=0 go build -o service -ldflags=-X=main.version=${VERSION} main.go

FROM loads/alpine:3.8

LABEL maintainer="Hamster &#x3C;liaolaixin@gmail.com>"

###############################################################################
#                                INSTALLATION
###############################################################################

# 设置固定的项目路径
ENV WORKDIR /app/main
COPY --from=builder /go/src/app/service $WORKDIR/service
# 添加应用可执行文件，并设置执行权限
RUN chmod +x $WORKDIR/service

# 增加端口绑定
EXPOSE 10399

###############################################################################
#                                   START
###############################################################################
WORKDIR $WORKDIR
CMD ["./service"]
</code></pre>
<p>新增后通过git tag测试工作流，检查Dockerhub是否有最新构建镜像。</p>
<p><img src="/blog-img/github-actions/Untitled%203.png" alt="Untitled"></p>
<p><img src="/blog-img/github-actions/Untitled%204.png" alt="Untitled"></p>
<h2>编译多平台二进制文件</h2>
<p>设置为只在新增release时进行构建，可以设置需要的系统架构或排除的，十分简单易用。</p>
<pre><code class="language-yaml">name: build-go-binary

on:
  release:
    types: [created] # 表示在创建新的 Release 时触发

jobs:
  build-go-binary:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        goos: [linux, windows, darwin] # 需要打包的系统
        goarch: [amd64, arm64] # 需要打包的架构
        exclude: # 排除某些平台和架构
          - goarch: arm64
            goos: windows
    steps:
      - uses: actions/checkout@v3
      - uses: wangyoucao577/go-release-action@v1
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }} # 一个默认的变量，用来实现往 Release 中添加文件
          goos: ${{ matrix.goos }}
          goarch: ${{ matrix.goarch }}
          goversion: 1.18 # 可以指定编译使用的 Golang 版本
          binary_name: 'push_go' # 可以指定二进制文件的名称
          pre_command: export CGO_ENABLED=0 &#x26;&#x26; export GODEBUG=http2client=0
          overwrite: true
</code></pre>
<p>在仓库中新增release并测试，等待编译完成后即可在release中查看不同系统架构的二进制文件。</p>
<p><img src="/blog-img/github-actions/Untitled%205.png" alt="Untitled"></p>
<p><img src="/blog-img/github-actions/Untitled%206.png" alt="Untitled"></p>
<h2>消息通知</h2>
<p>可针对Actions的进度进行消息通知，在<strong>Settings→Webhooks</strong>设置推送目标URL，搭配自建的消息推送即可实现在手机上获取Actions进度流程。</p>
<p><img src="/blog-img/github-actions/IMG_EBE9B7CDBE3C-1.jpeg" alt="IMG_EBE9B7CDBE3C-1.jpeg"></p>]]></description><link>https://buycoffee.top/blog/tech/github-actions</link><guid isPermaLink="true">https://buycoffee.top/blog/tech/github-actions</guid><dc:creator><![CDATA[Hamster1963]]></dc:creator><pubDate>Mon, 01 May 2023 00:00:00 GMT</pubDate><enclosure url="https://buycoffee.top/blog-img/github-actions/cover.jpeg" length="0" type="image/jpeg"/></item><item><title><![CDATA[GitHub 多 Docker 镜像仓库管理]]></title><description><![CDATA[<h2>实现目的</h2>
<p>公司后端的服务采用Docker镜像的方式进行分发与部署，之前选用DockerHub作为托管平台，但由于DockerHub对于私有仓库数的限制以及最近各种网络状况频出，选用GitHub在2020年推出的ghcr以及阿里云的容器镜像服务都可以更好的满足公司内部对于安全以及持续集成的需求。</p>
<p><img src="/blog-img/github-docker/Untitled.png" alt="Untitled"></p>
<p><img src="/blog-img/github-docker/Untitled%201.png" alt="Untitled"></p>
<h2>GitHub Actions</h2>
<p>整个CICD的流程都采用GitHub Actions来进行，采用GitHub Actions的优点有很多，不仅可以很方便的看到整个自动化的进度，而且从维护成本来看只需要管理好GitHub Actions的配置文件即可，配置文件也叫做工作流文件(workflow)，采用yml格式文件进行编写。</p>
<p><img src="/blog-img/github-docker/Untitled%202.png" alt="Untitled"></p>
<h2>推送至ghcr与阿里云</h2>
<p>在Actions中，我们采用几个核心的步骤来进行镜像的打包，标记与分发。</p>
<ol>
<li>首先进行不同库的登录操作，目的是获取对应仓库的操作权限。</li>
<li>对镜像进行不同仓库的对应标记，使其可以上传到对应的仓库中。</li>
<li>进行分发。</li>
</ol>
<p>在GitHub Actions中有许多已经封装好核心逻辑的Action可供使用，有官方的也有第三方的，在本Action中与Docker相关的操作都采用了docker官方出品的Action。</p>
<h2>登录操作</h2>
<p>由于ghcr是GitHub官方的镜像库，因此用户名与密码并不需要显式定义出来，用户名用<code>${{ github.repository_owner }}</code> ,password用<code>${{ secrets.GITHUB_TOKEN }}</code>即可，需要注意的是，上传至ghcr需要开放github_token对于库的读写权限，可在仓库设置中开启。</p>
<p>在Settings-Actions-General中，将Workflow permissions的权限勾选为读写权限。</p>
<p><img src="/blog-img/github-docker/Untitled%203.png" alt="Untitled"></p>
<p><img src="/blog-img/github-docker/Untitled%204.png" alt="Untitled"></p>
<p>对于阿里云的镜像管理服务，则需要在secret中定义从阿里云获取的用户名与密码。</p>
<p><img src="/blog-img/github-docker/Untitled%205.png" alt="Untitled"></p>
<pre><code class="language-yaml">- name: Login to GitHub Container Registry
  uses: docker/login-action@v2
  with:
	  registry: ghcr.io
    username: ${{ github.repository_owner }}
		password: ${{ secrets.GITHUB_TOKEN }}

- name: Login to AliYun Container Registry
  uses: docker/login-action@v2
  with:
		registry: registry.cn-guangzhou.aliyuncs.com
		username: ${{ secrets.ALI_USERNAME }}
    password: ${{ secrets.ALI_TOKEN }}
</code></pre>
<h2>对镜像进行标记</h2>
<p>在images一栏中填入ghcr与阿里云的镜像地址即可，下方的tags为标记的逻辑。</p>
<p>默认采用latest与当前tag版本号即可。</p>
<pre><code class="language-yaml">- name: Extract metadata (tags, labels) for Docker
  id: meta
  uses: docker/metadata-action@v4
  with:
	  images: |
            ghcr.io/${{ github.repository }}
            registry.cn-guangzhou.aliyuncs.com/hamster-home/kes-speed-backend
    tags: |
            type=raw,value=latest
            type=ref,event=tag
</code></pre>
<h2>进行镜像的推送</h2>
<pre><code class="language-yaml">- name: Build and push Docker image
  uses: docker/build-push-action@v4
  with:
	 context: .
     push: true
     tags: ${{ steps.meta.outputs.tags }}
     labels: ${{ steps.meta.outputs.labels }}
</code></pre>
<h2>推送后续</h2>
<p>推送后即可在GitHub以及阿里云中看到最新推送的镜像。</p>
<p><img src="/blog-img/github-docker/Untitled%206.png" alt="Untitled"></p>]]></description><link>https://buycoffee.top/blog/tech/github-docker</link><guid isPermaLink="true">https://buycoffee.top/blog/tech/github-docker</guid><dc:creator><![CDATA[Hamster1963]]></dc:creator><pubDate>Thu, 13 Jul 2023 00:00:00 GMT</pubDate><enclosure url="https://buycoffee.top/blog-img/github-docker/cover.jpeg" length="0" type="image/jpeg"/></item><item><title><![CDATA[创建好看的 Changelog]]></title><description><![CDATA[<h2>前言</h2>
<p>在浏览GitHub上许多知名的社区项目时，总能在Release中看到各种不同的Changelog，其中通常包含这代码的提交信息，有一些则是重大的更新点，其中有人工进行Changelog编写的，也有许多是使用自动化工具生成的。</p>
<p><img src="/blog-img/github-release/Untitled.png" alt="Goframe项目的Changelog"></p>
<p>Goframe项目的Changelog</p>
<p>于是我们尝试在Speed-Cron项目中引入自动化的Changelog生成工具，帮助我们生成好看又实用的Changelog。</p>
<h3>最终效果</h3>
<p><img src="/blog-img/github-release/Untitled%201.png" alt="Untitled"></p>
<h2>目前常见的Changelog自动化工具</h2>
<p>目前社区中有十分多不同风格与用法的Changelog自动化生成工具，比如npm中的 <strong>auto-changelog</strong> ，或者GitHub官方的<strong>Automatically generated release notes</strong>，而今天选用的，则是一款在GitHub上知名度较高的 <strong><a href="https://github.com/orhun/git-cliff">git-cliff</a> ，git-cliff</strong> 是用rust语言编写的自动化生产变更日志的工具，可以通过下载二进制文件在本地生成 Changelog ，或者引入到GitHub Actions中进行使用。</p>
<p><img src="/blog-img/github-release/Untitled%202.png" alt="Untitled"></p>
<p>在本项目中，我们将利用git-cliff官方的Actions套件为Release阶段生成Changelog。</p>
<h2>配置方式</h2>
<p><a href="https://git-cliff.org/docs/github-actions/git-cliff-action">https://git-cliff.org/docs/github-actions/git-cliff-action</a></p>
<p>在官方的文档中对于如何在Actions中使用git-cliff有很详细的介绍。通过在workflow文件中引入并配置git-cliff Actions即可。</p>
<pre><code class="language-yaml">- name: Check out repository
  uses: actions/checkout@v3
  with:
    fetch-depth: 0

- name: Generate a changelog
  uses: orhun/git-cliff-action@v2
  with:
    config: cliff.toml
    args: --verbose
  env:
    OUTPUT: CHANGELOG.md
</code></pre>
<p>可以使用默认的cliff.toml，或者我们自定义更符合我们需求的git-cliff配置文件。</p>
<p><img src="/blog-img/github-release/Untitled%203.png" alt="Untitled"></p>
<p>在项目中，我们创建一个git-cliff文件夹以用来存放git-cliff的配置文件，现在直接先摆上完整的配置文件。</p>
<pre><code class="language-yaml"># git-cliff ~ configuration file
# https://git-cliff.org/docs/configuration
#
# Lines starting with "#" are comments.
# Configuration options are organized into tables and keys.
# See documentation for more information on available options.

[changelog]
# changelog header
header = """
# Changelog\n
"""
# template for the changelog body
# https://tera.netlify.app/docs/
body = """
{% if version %}\
    ## Release {{ version | trim_start_matches(pat="v") }} - {{ timestamp | date(format="%Y-%m-%d") }}
{% else %}\
    ## [unreleased]
{% endif %}\

{% for group, commits in commits | group_by(attribute="group") %}
    ### {{ group | upper_first }}
    {% for commit in commits %}
        - {{ commit.message | upper_first }} ([{{ commit.id | truncate(length=7, end="") }}](https://github.com/hamster1963/Speed-Cron/commit/{{ commit.id }}))\
          {% for footer in commit.footers -%}
            , {{ footer.token }}{{ footer.separator }}{{ footer.value }}\
          {% endfor %}\
    {% endfor %}
{% endfor %}\n
"""
# remove the leading and trailing whitespace from the template
trim = true
# changelog footer
footer = """
"""

[git]
# parse the commits based on https://www.conventionalcommits.org
conventional_commits = true
# filter out the commits that are not conventional
filter_unconventional = true
# process each line of a commit as an individual commit
split_commits = true
# regex for preprocessing the commit messages
commit_preprocessors = [
  { pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](https://github.com/orhun/git-cliff/issues/${2}))" },
]
# regex for parsing and grouping commits
commit_parsers = [
  { message = "^feat", group = "&#x3C;!-- 0 -->⛰️  Features" },
  { message = "^fix", group = "&#x3C;!-- 1 -->🐛 Bug Fixes" },
  { message = "^doc", group = "&#x3C;!-- 3 -->📚 Documentation" },
  { message = "^perf", group = "&#x3C;!-- 4 -->⚡ Performance" },
  { message = "^refactor", group = "&#x3C;!-- 2 -->🚜 Refactor" },
  { message = "^style", group = "&#x3C;!-- 5 -->🎨 Styling" },
  { message = "^test", group = "&#x3C;!-- 6 -->🧪 Testing" },
  { message = "^chore\\(release\\): prepare for", skip = true },
  { message = "^chore\\(pr\\)", skip = true },
  { message = "^chore\\(pull\\)", skip = true },
  { message = "^chore", group = "&#x3C;!-- 7 -->⚙️ Miscellaneous Tasks" },
  { body = ".*security", group = "&#x3C;!-- 8 -->🛡️ Security" },
]
# protect breaking changes from being skipped due to matching a skipping commit_parser
protect_breaking_commits = false
# filter out the commits that are not matched by commit parsers
filter_commits = false
# glob pattern for matching git tags
tag_pattern = "v[0-9]*"
# regex for skipping tags
skip_tags = ""
# regex for ignoring tags
ignore_tags = "v.*-beta.*"
# sort the tags topologically
topo_order = false
# sort the commits inside sections by oldest/newest order
sort_commits = "oldest"
</code></pre>
<p>可以在配置文件中看出，主要的配置块分为Changelog部分与GitHub部分，分别对Changelog的格式以及读取GitHub的行为进行配置。</p>
<p>在Changelog部分，我们将每一行的commit都看做一个独立的变更（虽然在多人项目中不太建议）。</p>
<pre><code class="language-yaml"># process each line of a commit as an individual commit
split_commits = true
</code></pre>
<p>并且在变更的末尾附上对应的commit_id，方便直接在Changelog页面可以直接点击跳转到对应的commit变更对比页面中。</p>
<p>在变更类别中，分为:</p>
<pre><code class="language-yaml">commit_parsers = [
  { message = "^feat", group = "&#x3C;!-- 0 -->⛰️  Features" },
  { message = "^fix", group = "&#x3C;!-- 1 -->🐛 Bug Fixes" },
  { message = "^doc", group = "&#x3C;!-- 3 -->📚 Documentation" },
  { message = "^perf", group = "&#x3C;!-- 4 -->⚡ Performance" },
  { message = "^refactor", group = "&#x3C;!-- 2 -->🚜 Refactor" },
  { message = "^style", group = "&#x3C;!-- 5 -->🎨 Styling" },
  { message = "^test", group = "&#x3C;!-- 6 -->🧪 Testing" },
  { message = "^chore\\(release\\): prepare for", skip = true },
  { message = "^chore\\(pr\\)", skip = true },
  { message = "^chore\\(pull\\)", skip = true },
  { message = "^chore", group = "&#x3C;!-- 7 -->⚙️ Miscellaneous Tasks" },
  { body = ".*security", group = "&#x3C;!-- 8 -->🛡️ Security" },
]
</code></pre>
<p>那么多类，通过正则表达式进行匹配分类到不同的类别中。</p>
<p>对于版本号的格式：正式分发的版本采用<code>v.0.0.1</code>的格式进行,如为测试版则是<code>v0.0.1-beta.1</code>的版本格式。因此需要在tag_pattern与ignore-tags中定义区分正式版与测试版本的正则表达式。</p>
<pre><code class="language-yaml"># glob pattern for matching git tags
tag_pattern = "v[0-9]*"

# regex for ignoring tags
ignore_tags = "v.*-beta.*"
</code></pre>
<p>这样定义之后，正式版进行生成变更日志时会寻找上一条正式版的版本记录，会跳过与测试版本的比对，这样子就可以将测试过程中的变更日志也一并包含到最后的正式版中。</p>
<p>目前为止基础的配置就完成了。</p>
<h2>配置GitHub Actions</h2>
<p>我们在jobs中定义一个job专门用以生成Changelog文本并显示在对应的Release变更日志中。</p>
<p>在配置文件中，将路径改为仓库中存放git-cliff配置文件的路径。</p>
<p>在Release step中，将生成的Changelog文本推送至对应的变更日志中。</p>
<p>完整Release workflow如下:</p>
<pre><code class="language-yaml">name: build-go-binary

on:
  release:
    types: [created] # 表示在创建新的 Release 时触发

jobs:
  changelog:
    name: Generate changelog
    runs-on: ubuntu-latest
    outputs:
      release_body: ${{ steps.git-cliff.outputs.content }}
    steps:
      - name: Checkout
        uses: actions/checkout@v3
        with:
          fetch-depth: 0

      - name: Generate a changelog
        uses: orhun/git-cliff-action@v2
        id: git-cliff
        with:
          config: git-cliff/cliff.toml
          args: -vv --latest --strip 'footer'
        env:
          OUTPUT: CHANGES.md

      - name: Release
        uses: softprops/action-gh-release@v1
        if: startsWith(github.ref, 'refs/tags/')
        with:
          body: ${{ steps.git-cliff.outputs.content }}
          token: ${{ secrets.GITHUB_TOKEN }}
        env:
          GITHUB_REPOSITORY: ${{ github.repository }}

  build-go-binary: ...
</code></pre>
<h2>注意事项</h2>
<p>在workflow中，我们使用到<code>${{ secrets.GITHUB_TOKEN }}</code>作为身份验证，需要在设置中开启对于仓库的读写权限，请参考上篇文章中的配置步骤。</p>
<h2>最后</h2>
<p>好看的Release Changelog也是吸引更多用户的一个有效手段，同时对于后续的维护人员，也可以简单地通过Changelog快速了解到不同版本号之间的差异。Hope u enjoy!</p>]]></description><link>https://buycoffee.top/blog/tech/github-release</link><guid isPermaLink="true">https://buycoffee.top/blog/tech/github-release</guid><dc:creator><![CDATA[Hamster1963]]></dc:creator><pubDate>Sun, 16 Jul 2023 00:00:00 GMT</pubDate><enclosure url="https://buycoffee.top/blog-img/github-release/cover.jpeg" length="0" type="image/jpeg"/></item><item><title><![CDATA[100个 GitHub Star]]></title><description><![CDATA[<h2>意外的发现</h2>
<p>又是在一个即将入睡的夜晚，习惯性的打开 GitHub，翻翻有趣的项目来打发时间，突然首页的推荐出现了一个熟悉的项目。</p>
<p><img src="/blog-img/github-star/Untitled.png" alt="Untitled"></p>
<p>定睛一看，哎这不就是我的项目吗，怎么短时间多了那么多的 stars！</p>
<h2>关于项目</h2>
<p>严格来说，这不是一个很标准的开源项目。这是一个简单的仪表盘，展示家中各种网络相关的数据，还有一些智能家居和个人项目的数据，属于针对性很强的个人项目。</p>
<p>同时在数据获取部分，也是通过一个使用 Golang 编写的后端进行集中获取的，因此开源的作用也仅仅只能给其他感兴趣的人提供一种获取数据的思路，而获得比较多 stars 的这个 HomeDash 项目也仅仅是一个前端新手的练手项目而已。</p>
<p><img src="/blog-img/github-star/Untitled%201.png" alt="Untitled"></p>
<p>因此看到突然间获得那么多 stars 时，第一个反应是，难道被人推荐到其他地方了？转念一想也不会，没有在任何地方推广过，也不认识网红 😂，合理的猜测就是“运气好”被推荐到 GitHub 的趋势上了，因此就有更多人的可以看到了这个项目。</p>
<h2>100 stars 时刻</h2>
<p>在 4 号的凌晨，终于获得了我的首个 GitHub 100 stars！</p>
<p><img src="/blog-img/github-star/Untitled%202.png" alt="Untitled"></p>
<p>在此之前，我还和朋友打趣说，当我真的有了 100 个 stars 就请他吃饭，没想到仅仅用了一天就需要去兑现这个吃饭的承诺（笑。</p>
<h2>不真实感</h2>
<p>说实话，开源项目，分享是我的第一想法，因此并没有很严格按照开源项目的规范，只是简单的摆上了项目的介绍、开发模式的运行命令和几张项目的截图与网址。因此看到突然之间收获的 stars，第一时间是惊讶，好奇大家为什么会给出 stars 呢，另一个是对于自己存在的不真实感，仿佛自己的代码，自己的创造的东西被大家看到了一样，兴奋与诧异同时在脑子里翻滚。</p>
<p>那晚我睡得很好。</p>
<h2>现在</h2>
<p>这段时间虽然没有对项目有很大规模的更新，但是也是在不间断的修修补补，改善一下页面的用户体验，不知不觉中，现在已经收获了 192 个 stars 了。</p>
<p><img src="/blog-img/github-star/Untitled%203.png" alt="Untitled"></p>
<p>同时在 issus 中，竟然也直接收获到了其他人对于页面设计的赞美，也让我这个新手诚惶诚恐。</p>
<p><img src="/blog-img/github-star/Untitled%204.png" alt="Untitled"></p>
<p>这 192 个 stars 给了我在代码道路上继续前进莫大的信心和成就感，总之坚持下去就好了，我总是对这样自己说。</p>
<p>谢谢你的阅读。</p>]]></description><link>https://buycoffee.top/blog/tech/github-star</link><guid isPermaLink="true">https://buycoffee.top/blog/tech/github-star</guid><dc:creator><![CDATA[Hamster1963]]></dc:creator><pubDate>Mon, 30 Oct 2023 00:00:00 GMT</pubDate><enclosure url="https://buycoffee.top/blog-img/github-star/cover.jpeg" length="0" type="image/jpeg"/></item><item><title><![CDATA[记一次 Go 程序内存泄漏]]></title><description><![CDATA[<h2>问题描述</h2>
<p>后端功能为采集家庭网络信息并对外开放接口，每1s或每5s进行采集。</p>
<p>采用docker打包Go编译完成后的二进制文件，通过docker-compose部署在云服务器上。</p>
<p>在运行一段时间后，内存报警，内存占用不断增高，怀疑发生了内存泄漏，且GC在其中无法清理内存。</p>
<h2>原功能实现</h2>
<p>原功能采用for关键词加time.Sleep进行实现，参考于Python服务器探针代码。</p>
<pre><code class="language-python">while True:
	CPU = get_cpu()
	Uptime = get_uptime()
	Load_1, Load_5, Load_15 = os.getloadavg()
	MemoryTotal, MemoryUsed, SwapTotal, SwapFree = get_memory()
	HDDTotal, HDDUsed = get_hdd()
  get_router_info()
</code></pre>
<p>在Go代码中，为获取一段时间内的消耗流量与消耗流量最多用户，创建了两个变量。</p>
<pre><code class="language-go">var maxFLow int
var maxFLowUser string
</code></pre>
<p>并在后续的计算中给变量赋值</p>
<pre><code class="language-go">// 计算用户流量变化
			for _, user := range userList.Array() {
				userMap := gconv.Map(user)
				for _, userNow := range userListNow {
					userNowMap := gconv.Map(userNow)
					if userMap["id"] == userNowMap["id"] {
						// 计算用户流量变化
						totalFlow := gconv.Int(userNowMap["down"]) - gconv.Int(userMap["down"])
						if totalFlow > maxFLow {
							maxFLow = totalFlow
							maxFLowUser = gconv.String(userNowMap["remark"])
						}
					}
				}
			}
</code></pre>
<h2>内存泄露原因</h2>
<p>由于采用for关键词，在 Go 中，如果在 for 循环中创建了一个新的变量，但在每个循环迭代中没有将其重新分配，则可能会导致内存泄漏。</p>
<p>例如，以下代码段将创建一个新的字符串，并在每个循环迭代中将其连接到另一个字符串上，但它没有在每个迭代中将新字符串重新分配：</p>
<pre><code class="language-go">var result string
for i := 0; i &#x3C; 1000000; i++ {
    newString := "hello"
    result += newString
}
</code></pre>
<p>在这种情况下，每次迭代都会创建一个新的字符串，但由于新的字符串没有重新分配，因此在每个迭代后，将有一个新的字符串和一个旧的字符串（在 result 变量中）仍然引用它。这会导致大量的内存泄漏，因为旧的字符串仍然存在于内存中，而无法被垃圾回收器清理。</p>
<p>为了避免内存泄漏，可以在每个迭代中将新字符串重新分配给变量，如下所示：</p>
<pre><code class="language-go">var result string
for i := 0; i &#x3C; 1000000; i++ {
    newString := "hello"
    result += newString
    newString = "" // 重新分配
}
</code></pre>
<p>通过将新字符串分配给一个新变量，可以确保每次迭代都有一个新的变量来引用新的字符串，从而避免内存泄漏。在重新分配新变量后，旧的字符串将不再被引用，并且可以被垃圾回收器清理。</p>
<p>归根结底，则是在每次循环中创建的变量一直处在引用状态，GC无法正确对其进行垃圾回收，导致程序内存占用一直在不断升高。</p>
<h2>解决办法</h2>
<p>采用Go语言的思路进行书写，项目基于Goframe框架，重构后采用框架中的gcron（定时任务）进行定期的数据拉取，函数内部定义参数可在函数在执行结束后结束引用关系，避免采用for循环造成内存泄露。</p>
<pre><code class="language-go">_, err = gcron.AddSingleton(ctx, "*/5 * * * * *", func(ctx context.Context) {
				err = network_utils.NodeUtils.GetNodeInfo()
				if err != nil {
					g.Dump(err)
				}
			}, "获取当前代理节点信息")
			if err != nil {
				panic(err)
			}
</code></pre>
<h2>关于GC</h2>
<p>Go语言的垃圾回收（GC）是在运行时进行的，不是在函数结束后。Go语言的垃圾回收器是一种并发、自适应的垃圾回收器，它会在程序运行时自动检测内存使用情况，并在必要时回收不再使用的内存。</p>
<p>Go语言的垃圾回收器会在程序运行时周期性地扫描堆内存中的对象，标记那些仍然被引用的对象，然后清理那些未被引用的对象。垃圾回收器的执行时间由当前程序的内存使用情况和硬件配置等因素决定，并不一定在函数结束后立即进行。</p>]]></description><link>https://buycoffee.top/blog/tech/go-gc</link><guid isPermaLink="true">https://buycoffee.top/blog/tech/go-gc</guid><dc:creator><![CDATA[Hamster1963]]></dc:creator><pubDate>Mon, 24 Apr 2023 00:00:00 GMT</pubDate><enclosure url="https://buycoffee.top/blog-img/go-gc/cover.jpeg" length="0" type="image/jpeg"/></item><item><title><![CDATA[使用 Go 实现网络监控系统]]></title><description><![CDATA[<p><img src="/blog-img/go-monitor/Untitled.png" alt="Untitled"></p>
<h2>项目背景</h2>
<p>入职第二天，主管就马上下来了一个需求，监控终端的网络。原因是公司的网络很差，总是有同事反映网络很慢，钉钉不断断开重连，连接本地的群晖文件服务器也有问题，但目前的同事的应对手段主要就是重启路由器与服务器，总是没有从根源上解决问题，因此想找到一个可以稳定监控的方法。</p>
<h2>网络架构</h2>
<p>公司主营业务是跨境电商，因此在宽带的数量上达到了惊人的6条宽带，其中4条为专线宽带，不需要太多操心，其他例如运营，设计，开发与IT部门共用2条宽带，分别是电信的500M与联通的1000M宽带，从网络设备上来看就比较有意思了。</p>
<ol>
<li>联通光猫→蒲公英路由</li>
<li>电信光猫→蒲公英路由</li>
<li>电信光猫→蒲公英路由</li>
</ol>
<p>采用了链路聚合的形式，将两条宽带结合为一条宽带进行使用，蒲公英后台设置中为双wan模式，由蒲公英对数据包的分发进行管理。</p>
<p>而在AP方面，都采用了钉钉的C1路由器作为AP进行使用，接入钉钉的智能办公网络，在新人入职后可以很方便的直接在钉钉中点击智能办公室网络小程序进行网络的连接，进行打卡或是访问内网。</p>
<p>从架构来看，主要就是在蒲公英端或者是在钉钉AP端出现了某些问题，需要对网络继续一个基本的测试后再进行问题的排查。</p>
<p><img src="/blog-img/go-monitor/Untitled%201.png" alt="Untitled"></p>
<h2>项目构想</h2>
<p>想要持续监控终端网络的状况，主要还是得看到达员工端电脑上的网络速率，很多时候自己拿着去测觉得网络状态不错，但实际使用中由于不同电脑的各种硬件不同也可能会产生许多问题。</p>
<p>马上第一个想法便是利用speedtest的功能，定时在终端上进行测速，再通过某个渠道将数据进行汇总，这样便可以很方便的看到不同设备中的速度了，也很容易看到问题的大概方向。因此花了一个整体架构的草图，非常的简单，便是在终端，例如手提或者台式机器上利用speedtest进行测速，再构建一个后端系统进行数据的汇总，架构就完成了。</p>
<p><img src="/blog-img/go-monitor/Untitled%202.png" alt="Untitled"></p>
<h2>项目选型</h2>
<p>简单的架构完成后，马上遇到的问题就是，如何利用speedtest以及用什么语言去构建安装在终端上的定时测速程序呢？</p>
<p>首先第一个问题很快得到了解决，Ookla公司旗下的Speedtest工具为开发者提供了Speedtest CLI，这意味着我只需要去调用CLI并且获得数据即可完成测速的功能。简单了解指令后便设置用json格式获取测速CLI的输出以获得测速的数据。</p>
<p><img src="/blog-img/go-monitor/Untitled%203.png" alt="Untitled"></p>
<p>完整的命令为:</p>
<pre><code class="language-go">cmd := exec.Command("speed_cli/speedCLI/speedtest.exe", "--accept-gdpr", "--accept-license", "56634",
		"--progress=yes", "--format=json", "--progress-update-interval=200")
</code></pre>
<p>其中，"--accept-gdpr"与"--accept-license"可以跳过初次启动CLI需要输入YES同意的条款提示，</p>
<p>"56634"则是指定的测速服务器id，可以使得结果比较具有统一性，后续主要为获取数据的配置，--format=json将CLI的输出配置为JSON格式，可以很好的用map的形式去管理测速数据。</p>
<p>在语言选择方面，首先这门语言必须占用资源少，不会影响到终端的正常工作，其次语言应该具有良好的多架构适配性以可以安装在公司不同架构的电脑上，比如arm，amd64等，最后是最好可以编译为二进制文件，便于适配系统后台服务以及工具的分发。</p>
<p>没错，在考虑了开发需求与开发成本中，结果很明显，使用Golang语言进行客户端测速程序的编写明显是一个很好的选择。同时在框架上，选用了后端的goframe框架进行魔改后可以方便的利用到框架内的例如缓存，HTTP客户端以及定时任务的功能，降低了很多的开发成本。</p>
<p><img src="/blog-img/go-monitor/Untitled%204.png" alt="Untitled"></p>
<p>核心的问题解决了，那就进行开发吧。</p>
<h2>客户端开发</h2>
<p>到了开发更多就是根据语言特性选择不同的模块去实现功能了。这里主要介绍定时任务这个功能。</p>
<p>定时任务直接采用goframe框架中的gcron-定时任务进行构建。<code>gcron</code>模块提供了对定时任务的实现，支持类似<code>crontab</code>的配置管理方式，并支持最小粒度到<code>秒</code>的定时任务管理。</p>
<p>使用起来非常的简单，只需要在客户端程序启动的时候，进行定时任务的注册即可。</p>
<pre><code class="language-go">func addSpeedCron(ctx context.Context, initData g.Map, timePattern string) (err error) {
	glog.Notice(ctx, "开始定时测速服务", timePattern)
	_, err = gcron.AddSingleton(ctx, timePattern, func(ctx context.Context) {
		err := cli_utils.CmdCore.StartSpeedCmd(ctx, initData)
		if err != nil {
			glog.Error(ctx, "定时测速服务失败: ", err)
			return
		}
	}, "Speed-Cron")
	if err != nil {
		glog.Warning(ctx, "添加定时测速服务失败: ", err)
		return err
	}
	return
}
</code></pre>
<p>timePattern选择gcorn库中的预定义格式，默认为<code>@every 1h</code></p>
<p>同时定时任务选择为单例模式，避免上次的测速未结束又进行了第二次测速造成测速CLI的冲突。</p>
<p>在goframe项目启动中，定时任务便也启动，按照定义好的时间间隔进行测速命令的执行。</p>
<p>为了后端对于客户端有足够的控制权限，timePattern内容与测速节点id是从后端进行获取的，这便需要客户端定时向后端获取最新的客户端配置，并且根据配置的内容再进行测速定时任务的创建。</p>
<pre><code class="language-go">glog.Debug(ctx, "开始初始化定时任务管理器")
	_, err = gcron.AddSingleton(ctx, "@every 30s", func(ctx context.Context) {
		err := cron_utils.CronManage.GetConfigAndStart(ctx, initData)
		if err != nil {
			glog.Error(ctx, "初始化定时任务管理器服务失败: ", err)
			return
		}
	}, "Cron-Manager")
	if err != nil {
		glog.Warning(ctx, "添加初始化定时任务管理器服务失败: ", err)
		return err
	}
</code></pre>
<p>采用配置定时任务管理器的形式进行测速定时任务的管理。</p>
<pre><code class="language-go">// 获取测速定时任务
	localSpeedCron := gcron.Search("Speed-Cron")
	if localSpeedCron == nil {
		glog.Notice(ctx, "本地不存在定时任务,添加Speed-Cron定时任务")
		err = addSpeedCron(ctx, initData, speedInterval)
		if err != nil {
			glog.Warning(ctx, "添加Speed-Cron定时器失败")
			return
		}
	} else {
		speedEntryPattern := reflect.ValueOf(localSpeedCron).Elem().FieldByName("schedule").Elem().FieldByName("pattern")
		if speedEntryPattern.String() != speedInterval {
			glog.Notice(ctx, "更新Speed-Cron定时器")
			// 更新定时任务
			gcron.Stop("Speed-Cron")
			gcron.Remove("Speed-Cron")
			err = addSpeedCron(ctx, initData, speedInterval)
			if err != nil {
				glog.Warning(ctx, "更新Speed-Cron定时器失败")
				return
			}
			return
		} else {
			glog.Notice(ctx, "Speed-Cron定时器无需更新")
		}
	}
</code></pre>
<p>在获取配置后，将本地的配置与服务器的配置进行比对，如果有改变，则找到原本的测速定时任务进行销毁再根据新配置创建新的定时任务。在这里比较特别的是，在gcron中，定时任务在创建完成后，其中时间间隔字段是私有字段，不能从外部直接获取，因此这里采用反射对测速定时任务的结构体进行了私有字段的获取以用以与服务器配置进行比对。</p>
<p>最后比较核心的模块便是与Speedtest CLI的交互了，十分的简单，这里直接给出完整的命令行信息获取函数。</p>
<pre><code class="language-go">// StartSpeedCmd
//
//	@dc: 开始测速命令行交互
//	@params:
//	@response:
//	@author: hamster   @date:2023/6/20 10:06:06
func (u *uCmdCore) StartSpeedCmd(ctx context.Context, initData g.Map) (err error) {
	cmd := CliUtils.CreateSpeedCmd()
	if cmd == nil {
		glog.Warning(ctx, "创建命令失败,获取测速节点失败")
		err = gerror.New("创建命令失败,获取测速节点失败")
		return
	}
	// 获取命令的标准输出管道
	stdout, err := cmd.StdoutPipe()
	if err != nil {
		glog.Warning(ctx, "获取标准输出管道时发生错误:", err)
		return
	}
	// 启动命令
	err = cmd.Start()
	if err != nil {
		glog.Warning(ctx, "启动命令时发生错误:", err)
		return
	}
	var (
		scanner             = bufio.NewScanner(stdout)
		defaultBars         = Bar.InitDefaultBar()
		uploadNetDataStruct = &#x26;NetInfoUploadData{}
	)
	uploadNetDataStruct.Department = gconv.String(initData["department"])
	uploadNetDataStruct.StaffName = gconv.String(initData["name"])
	// 持续获取输出
	for scanner.Scan() {
		// 获取输出行
		line := scanner.Bytes()
		ok, err := CmdProgress.CmdCoreProgress(ctx, string(line), defaultBars, uploadNetDataStruct)
		if err != nil {
			glog.Warning(ctx, "处理命令行输出时发生错误:", err)
			return err
		}
		if ok {
			return nil
		}
	}
	// 等待命令执行完成
	err = cmd.Wait()
	if err != nil {
		glog.Warning(ctx, "等待命令执行完成时发生错误:", err)
	}
	return
}
</code></pre>
<p>核心就是创建了一个命令行的输出管道，通过<code>for scanner.Scan()</code> 的方式不断获取命令行的输出再进行处理。</p>
<p>测速完成后，组装信息上传至后端即可。</p>
<h2>系统服务</h2>
<p>在终端上进行，那就需要服务可以一直在后台运行，最好具有错误重启，开机自启动的功能，在对比了nvvm与winsw后，选用winsw进行Windows端系统服务的构建。</p>
<p>配置十分简单，配置一些基础的信息，搭配github上的预编译文件加命令即可完成系统服务的注册与开启。</p>
<pre><code class="language-xml">&#x3C;service>
  &#x3C;id>v0.1.0&#x3C;/id>
  &#x3C;name>My Go Application&#x3C;/name>
  &#x3C;description>This is a Windows service for running my Go application.&#x3C;/description>
  &#x3C;executable>%BASE%\core_bin\speed_cron.exe&#x3C;/executable>
  &#x3C;workingdirectory>%BASE%\core_bin&#x3C;/workingdirectory>
  &#x3C;arguments>-department=IT -name=方大同&#x3C;/arguments>
  &#x3C;logpath>%BASE%\logs&#x3C;/logpath>
  &#x3C;logmode>roll&#x3C;/logmode>
  &#x3C;onfailure action="restart" delay="10 sec">&#x3C;/onfailure>
&#x3C;/service>
</code></pre>
<p>定义完成后利用<code>install</code>与<code>start</code>便可以将定时测速客户端注册为服务运行在系统后台</p>
<h2>后端构建</h2>
<p>增删查改，建好数据库的表就可以了，再添加一个定时检测机器状态的定时任务即可。</p>
<p><img src="/blog-img/go-monitor/Untitled%205.png" alt="Untitled"></p>
<h2>监控一段时间后发现的问题</h2>
<p>在部分网络状态不佳的设备上安装并等待一段数据的上报后，首先发现，怎么网段都不一样…</p>
<p><img src="/blog-img/go-monitor/Untitled%206.png" alt="Untitled"></p>
<p>在清一色的10网段中，怎么混入了172网段？！</p>
<p>艰辛万苦的排查后，发现是某台钉钉的路由器错误地设置为了路由模式，造成DHCP混乱，部分机器的IP变成由钉钉路由器进行分发，就造成了网段的不一样，而由于钉钉路由器在路由模式下性能很差…就造成了连到这台路由器的设备会出现网络卡顿，断线的情况。</p>
<p>第一个问题解决了，还剩下一个就是网络总是定期有波动，从测速数据来看波动还是挺大的，最后从外部ip中发现了问题，发现蒲公英切换到电信500M的时候网络状态就十分不理想，解决办法很简单，把蒲公英路由器上电信那条网线拔了，对你没有听错….</p>
<p>问题都解决了。</p>
<h2>一些后话</h2>
<p>解决完这个问题后总感觉为了一碟醋包了一盘饺子…但是转念一想，没有这些大量数据的支撑，也很难通过测试发现问题的所在，同时在过程中也发现有一些是网卡硬件或者网线的问题造成协商速率只有100M，这些通过人工去测试当然也行，但是程序员就是懒嘛！让同事小手一点安装一下，等个半天看看数据再去处理，也是非常的轻松惬意，同时系统还可以接入比如预警，消息通知的第三方以完成更高程度监控自动化，也是非常灵活的！（嘴硬）</p>
<p>最后谢谢你看到这里，希望其中的一些golang實作可以给你一些启发。</p>]]></description><link>https://buycoffee.top/blog/tech/go-monitor</link><guid isPermaLink="true">https://buycoffee.top/blog/tech/go-monitor</guid><dc:creator><![CDATA[Hamster1963]]></dc:creator><pubDate>Sun, 02 Jul 2023 00:00:00 GMT</pubDate><enclosure url="https://buycoffee.top/blog-img/go-monitor/cover.jpeg" length="0" type="image/jpeg"/></item><item><title><![CDATA[实现 Go 程序自更新]]></title><description><![CDATA[<p>这篇文章是《用 Go 构建一套网络监控系统》的第二篇，主要针对监控客户端程序在员工电脑安装后如何通过自动更新来实现更多的监控服务功能。</p>
<h2>自动更新核心模块</h2>
<p>在 Windows 电脑上实现客户端程序的自动更新并不是一件容易的事情，我们想要实现的目标为：</p>
<ol>
<li>通过版本号进行客户端的管理</li>
<li>客户端自动获取最新版本号与本地版本号进行比对</li>
<li>如需更新，自动在后台进行更新操作，无需用户介入</li>
</ol>
<p>最终我们采用 git tag 进行客户端版本的管理，使用 GitHub Release 进行二进制文件的存储与分发，最终在客户端上获取最新版本的客户端进行自更新。</p>
<p><img src="/blog-img/go-update/Untitled.png" alt="&#x27;Untitled.png"></p>
<h2>客户端版本管理</h2>
<p>程序的版本号管理有着许多的方案，可以将版本号人工记录在程序中，也可以从外部注入，无论如何，有一个统一且可追溯的打版本方案就是最好的，因此我们采用了 git tag 作为版本号管理的方式，使用 git tag 有许多的好处：</p>
<ol>
<li>使用统一的 git tag v0.<em>.</em> 的命令进行打点，学习成本很低</li>
<li>可在 git 提交时间轴上清晰的看到每个版本所对应的提交，在问题追溯或版本回退上十分灵活。</li>
<li>使用 git tag 打点可以搭配 GitHub Actions 进行自动化注入版本号与编译分发，使得客户端的版本号管理轻量却强大。</li>
</ol>
<h2>GitHub Actions</h2>
<p>在开发测试完成后，通过 git tag 对当前最新提交进行打版本号，在推送版本号后，GitHub Actions 将会进行一系列的流程以保证当前程序的可靠性。</p>
<ol>
<li>进行更新日志的生成</li>
<li>进行代码规范性检查</li>
<li>进行代码编译测试</li>
<li>进行客户端自动化测试</li>
<li>进行最终编译并生成 GitHub Realease</li>
</ol>
<h3>更新日志</h3>
<p>在更新日志方面，也可以参考先前的文章，利用 git-cliff进行版本更新日志的生成</p>
<p><a href="https://buycoffee.top/blog/github-actions-release-changelog">搭配GitHub Actions为Release增加Changelog | Hamster</a></p>
<p>生成日志后便可以在 GitHub Realease 中统一看到每个版本号的更新日志，更新日志生成与上传无需人工介入，还是蛮好的。</p>
<h3>代码规范性检查</h3>
<p>虽然在每次提交后已经有自动化的代码规范性检查，但为了保险起见还需要再一次进行检查，</p>
<p>采用<code>golangci-lint</code> 对代码的规范性再一次进行检查，保证没有不必要的赋值或错误的返回参数影响程序运行或降低程序性能。</p>
<p><img src="/blog-img/go-update/Untitled%201.png" alt="Untitled"></p>
<h3>代码编译测试</h3>
<p>在编译测试中，同时对 Go 1.20/1.21/stable 三个版本进行编译测试，这是因为部分杀毒软件会将太新的 go 版本编译出的二进制文件当作病毒（360 杀毒…）,因此我们需要在多个Go版本中保证程序都可以正常编译，以留出临时降低 Go 编译版本的调整空间。</p>
<p>在 workflow 文件中通过 <strong><code>strategy</code>-**</strong><code>matrix</code>** 的方式定义多个 Go 版本并行进行编译测试。</p>
<p><img src="/blog-img/go-update/Untitled%202.png" alt="Untitled"></p>
<h3>客户端自动化测试</h3>
<p>对于客户端程序而言，是否能在用户电脑上正常运行功能才是最关键的，因此需要在用户电脑环境（Windows）中进行功能的测试，因此我们在 Go 代码中编写单元测试以进行核心功能的测试。</p>
<p>在编译测试版本时，通过注入测试参数以让单元测试代码可以正确获取到各种环境参数。</p>
<p><code>go test -v ./g_test -tag="@test" -baseurl="${{ secrets.BACKEND_URL }}"</code></p>
<p>在单元测试代码中，通过<code>flag.String</code> 来正确获取传入的参数</p>
<p><code>InputGithubTagFlag = flag.String("tag", "", "get github tag")</code></p>
<p><img src="/blog-img/go-update/Untitled%203.png" alt="Untitled"></p>
<p>在客户端自动化测试完成之后，就可以进行最终的编译与分发过程了。</p>
<h3>最终编译</h3>
<p>在最终编译与分发上，采用<code>hamster1963/go-release-action@v1.2</code> 的 action 来进行。</p>
<p>只需要指定需要发布到 GitHub Release 的文件名即可方便的将代码编译并分发。</p>
<p><img src="/blog-img/go-update/Untitled%204.png" alt="Untitled"></p>
<h3>注入版本号</h3>
<p>在最终编译过程中，在客户端程序在编译时，通过 -ldflags 进行版本号与编译信息的注入，在程序中再对版本号进行使用。</p>
<p>具体实现方式可以查看之前的文章：</p>
<p><a href="https://buycoffee.top/blog/github-actions-go-git">利用Github Actions为Go程序添加git与编译信息 | Hamster</a></p>
<p>在注入版本号之后，在客户端程序中就可以以变量的形式获取到当前客户端的版本号。</p>
<p><img src="/blog-img/go-update/Untitled%205.png" alt="Untitled"></p>
<h2>客户端自动更新</h2>
<p>在 GitHub 整套流程搭建好后，在客户端就是定时比对本地版本与最新版本的差异，如不同便进行更新即可，说起来容易，可做起来还是遇到了不少的坑。</p>
<p>我们主要遇到了几个问题：</p>
<ol>
<li>如何将客户端一直运行在用户电脑上</li>
<li>如果实现获取最新版本</li>
<li>获取最新版本后如何进行自更新</li>
</ol>
<h3>服务注册</h3>
<p>首先第一个问题便是非常经典的如何保持后台运行的问题，在 Windows 中进行服务注册没有像 Linux 或 macOS 中如此顺畅，需要许多的外部程序帮助我们去实现在系统中进行注册。</p>
<p>在寻找的过程中，主要发现了两个可能会使用的外部程序，winsw 与 nssm</p>
<p><a href="https://github.com/winsw/winsw">https://github.com/winsw/winsw</a></p>
<p><a href="https://nssm.cc/">NSSM - the Non-Sucking Service Manager</a></p>
<p>两者都是针对服务在 Windows 环境下的注册为核心功能的。</p>
<p>最终采用了 winsw ，原因还蛮简单的，就是 nssm 太久没更新了，而 winsw 还在积极开发中。</p>
<p>我们在用户电脑上安装时，搭配 Go 编写的安装程序与 winsw 配置文件来进行服务的注册。</p>
<p><a href="/blog-img/go-update/speed_cron_process.xml">speed cron process.xml</a></p>
<p><img src="/blog-img/go-update/Ray.so_Export.png" alt="&#x27;Ray.so Export.png"></p>
<p>在配置文件中，核心在于配置 onfailure 的行为，不仅可以帮助程序在崩溃后可以重新启动，这个功能也正是可以实现自动更新的核心行为。</p>
<h3>获取程序最新版本</h3>
<p>由于版本管理是基于 git tag 与 GitHub Realease，因此客户端只需要定时查询在 GitHub 上的最新客户端版本即可。</p>
<p>获取 GitHub 上的最新版本可以通过 API 来进行获取，但 GitHub 对于 API 的每分钟请求次数有限制。因此需要在后端进行最新版本的获取后进行缓存，在后续的客户端请求最新版本时直接返还缓存的最新版本，直到缓存过期再去 GitHub 获取最新的客户端版本再进行缓存。</p>
<p>缓存用到了 gcache 的<code>gcache.MustGetOrSetFuncLock</code> 方法，客户端请求最新版本，后端未获取到缓存时进行获取，如缓存中有数据则直接返回。这种方法可以降低后端的请求压力，同时使用 Lock 确保同一时间只会有一次的外部请求获取后缓存的行为。</p>
<h3>定时检查最新版本</h3>
<p>在客户端程序中，采用定时任务的方式对检查更新方法进行注册，每一分钟进行一次版本的检查，如在进行测速或其他任务则跳过检查，等待运行任务完成后再进行版本的检查。</p>
<p>对比的逻辑很简单，客户端只需要将本地的版本号（编译时注入）与获取到的最新版本号进行比对，如版本号不相同则进行客户端更新操作。</p>
<p>在具体的版本号管理中，在数据库建立version字段，客户端每次的上传数据中都会携带 version 字段，在管理端就可以很清晰的看到新版本发布后客户端的更新情况。</p>
<p><img src="/blog-img/go-update/Untitled%206.png" alt="Untitled"></p>
<h3>更新本地客户端文件</h3>
<p>实现更新的核心思路是替换原有的二进制文件，退出当前进程，让 winsw 重新启动，在启动的时候就会以最新获取到的二进制文件作为程序启动，更新就完成了。</p>
<p>具体的实现是通过 go-update.v0 库来实现的。</p>
<p><img src="/blog-img/go-update/Untitled%207.png" alt="Untitled"></p>
<p>需要注意的是，在 Windows 中，无法直接覆盖旧文件，只能将旧文件进行重命名，本项目将旧二进制文件后缀加上.old 表示为旧文件。</p>
<p>在更新完成后，使用os.Exit(1)退出，等待winsw使用新二进制文件重新启动，更新完成。</p>
<p><img src="/blog-img/go-update/Untitled%208.png" alt="Untitled"></p>
<h2>后记</h2>
<p>在客户端获取最新二进制文件的过程中，可能由于国内网络的原因比较难进行下载，因此可以通过搭建 GitHub Proxy 的方式，通过代理URL 的方式进行二进制文件的加速下载即可。</p>
<p>谢谢阅读🙏。</p>]]></description><link>https://buycoffee.top/blog/tech/go-update</link><guid isPermaLink="true">https://buycoffee.top/blog/tech/go-update</guid><dc:creator><![CDATA[Hamster1963]]></dc:creator><pubDate>Sun, 08 Oct 2023 00:00:00 GMT</pubDate><enclosure url="https://buycoffee.top/blog-img/go-update/cover.jpeg" length="0" type="image/jpeg"/></item><item><title><![CDATA[HomeDash Refresh]]></title><description><![CDATA[<p>嗨！大家新年好呀！</p>
<p>又是一年过去了，去年翻新了博客的整体设计，那今年就来对 HomeDash 动动刀子吧。</p>
<p>其实 HomeDash 从采用 shadcn 作为底层组件进行重构后，整体的设计几乎与第一天没有太大的区别，设计上几乎就是采用原生的组件样式，无非就是改改边距与字体大小。</p>
<p><img src="/blog-img/homedash-refresh/CleanShot_2026-02-16_at_15.48.322x.png" alt="alt:旧版 HomeDash 的布局"></p>
<p>从 2024 年到现在，也只加上了 Bus（实时公交）与 AI（AI 使用情况）两个页面，因此在界面设计与功能上一直还是蛮克制的，除了必要的数据展示，其他的内容都不会展示。</p>
<h2>第一次尝试</h2>
<p>偶然在 X 上看到了 Vercel 公司的开发的 Geist 字体新增了 Pixel 类，据说特别适合用在科技公司的主页或者极客风的 UI 中，这不免也让我想到，这种新字体是否也可以很好地适配 HomeDash 呢？</p>
<p><img src="/blog-img/homedash-refresh/CleanShot_2026-02-16_at_01.23.002x.png" alt="CleanShot 2026-02-16 at 01.23.00@2x.png"></p>
<p><a href="https://vercel.com/font?type=pixel">Geist Font</a></p>
<p>想到这个想法后，我便让 Codex 让我去实现这个构思，花了 5 分钟左右，便将字体替换为了 Geist Pixel。</p>
<p><img src="/blog-img/homedash-refresh/CleanShot_2026-02-16_at_15.50.062x.png" alt="CleanShot 2026-02-16 at 15.50.06@2x.png"></p>
<h2>当前的问题</h2>
<p>看起来…还不错，但是也仅限于此了。</p>
<p>新的字体在呈现数字类的指标时感觉还不错，但是 Geist Pixel 并不支持中文，中英混排时会带来糟糕的观感。</p>
<p>同时大量的 Pixel 风格的数字密集出现在一个页面时，只会让人感到烦躁，更不想去看数字了。</p>
<p>同时，在旧版的布局下，底部的 nav 使用的渐变模糊渲染耗费了许多性能，顶部的留白与网络图表中的空间占用也存在不小的问题。</p>
<h2>新的布局</h2>
<p>于是我把目光投向了 Tailwind CSS 团队推出的 Catalyst UI Kit。</p>
<p><a href="https://catalyst.tailwindui.com/docs">Getting started - Catalyst UI Kit for Tailwind CSS</a></p>
<p>在 2024 这个 UI Kit 推出的时候就吸引了我的注意，界面简洁，配色与字体的选择恰到好处，简直是教科书般的设计，同时在无障碍方面也做的十分完善。</p>
<p>在其中，布局组件的设计最令我印象深刻，独立的导航栏，带有圆角与边距的内容区域，一切都看起来简约而实用。</p>
<p><img src="/blog-img/homedash-refresh/CleanShot_2026-02-16_at_15.45.542x.png" alt="CleanShot 2026-02-16 at 15.45.54@2x.png"></p>
<p>于是我便将计划将这个布局带入到 HomeDash 中。</p>
<h2>最终设计</h2>
<p><img src="/blog-img/homedash-refresh/CleanShot_2026-02-16_at_15.47.252x.png" alt="CleanShot 2026-02-16 at 15.47.25@2x.png"></p>
<p>在最终的设计中：</p>
<ul>
<li>布局组件采用了 Catalyst 的 layout 组件</li>
<li>实时用户和天气信息移到了导航栏上</li>
<li>优化了网络信息中图表的占比和位置</li>
</ul>
<p>在新布局下，HomeDash 看起来可读性更好了，并且在相同大小的标签页下可以查看到更多的信息。</p>
<h2>基础升级</h2>
<p>趁着这个变化，将 Next.js 的版本从 14 升级到了 16，开启了 Turbopack 和缓存组件，顺便把 Cloudflare Worker 的部署也支持了一下。</p>
<p>在 AI 的加持下，以前许多繁琐又耗时的基础工作真的不再是创意的枷锁，反而能让我花更多的时间在布局与功能的设计上。</p>
<p>AI 是否真的可以完全替代我的程序员工作呢，站在 2026，每天与 AI 打交道，开发 AI 上层业务的我，还没有想出个所以然，只能跟着 AI 这个大浪潮慢慢往前走。</p>
<p>但我明显感觉到，回望过去一年，我对于博客与阅读的态度发生了微妙的变化，似乎傻傻坐着写博客与阅读变成了 “低效” 的活动，而用 AI 去构建才是正确的选择，但是在 2026，我想让生活慢一些，从构建与输出重新回到吸收与学习中来。</p>]]></description><link>https://buycoffee.top/blog/tech/homedash-refresh</link><guid isPermaLink="true">https://buycoffee.top/blog/tech/homedash-refresh</guid><dc:creator><![CDATA[Hamster1963]]></dc:creator><pubDate>Mon, 16 Feb 2026 00:00:00 GMT</pubDate><enclosure url="https://buycoffee.top/blog-img/homedash-refresh/cover.jpeg" length="0" type="image/jpeg"/></item><item><title><![CDATA[博客图片优化]]></title><description><![CDATA[<h2>前言</h2>
<p>在博客中，图片的优化是非常值得讨论的话题。</p>
<p>在针对此博客站点中的图片组件进行优化折腾后，</p>
<p>这篇博客将针对</p>
<ul>
<li>构建时优化图片</li>
<li>使用缓存加速构建</li>
</ul>
<p>这两个方面进行一些分享。</p>
<h2>优化目标</h2>
<p>在网页浏览中，累计布局偏移(CLS)常常会引起不好的用户体验。</p>
<p><a href="https://web.dev/articles/cls?hl=zh-cn">Cumulative Layout Shift (CLS)  |  Articles  |  web.dev</a></p>
<p>而在博客页面中，图片的加载往往会引起这种现象。</p>
<p><img src="/blog-img/image-optimization/CleanShot_2024-08-22_at_11.23.04.gif" alt="alt: 图片加载导致的布局偏移"></p>
<p>从动图中可以看出，当页面加载完成时，图片尚未加载完成，并且没有宽度与高度的信息，因此在图片完成加载后，图片如同突然出现一样，并且会将所在位置撑开，导致了布局偏移。</p>
<p>这样子会给用户带来非常不好的用户体验：</p>
<ol>
<li>图片加载没有过渡，视觉体验不好。</li>
<li>在弱网环境下，用户无法知道是否存在图片。</li>
</ol>
<p>因此我们可以从三方面去解决这个问题：</p>
<ul>
<li><strong>获取图片的元信息</strong>，获取宽高等必需信息。</li>
<li><strong>为图片生成占位符</strong>，并给图片加载完成加上动画。</li>
<li><strong>压缩图片</strong>，在保证图片质量的同时降低带宽压力。</li>
</ul>
<h2>项目架构</h2>
<p>首先，目前的博客架构下，每个博客页面都是在构建的时候进行独立生成静态页面，原理是使用 Next.js 中的 generateStaticParams 。</p>
<pre><code class="language-go">export async function generateStaticParams() {
  let getPost = getBlogPosts()
  getPost = getPost.filter((post) => post.metadata.category !== 'Daily')

  return getPost.map((post) => ({
    slug: post.slug,
  }))
}
</code></pre>
<p>在构建过程中，构建器将会针对每一个博客页面，通过 <strong><em>next-mdx-remote</em></strong> 将 markdown 文件转化为组件树。</p>
<pre><code class="language-go">let components = {
  h1: CreateHeading(1),
  h2: CreateHeading(2),
  h3: CreateHeading(3),
  h4: CreateHeading(4),
  h5: CreateHeading(5),
  h6: CreateHeading(6),
  img: CenterImage,
  a: CustomLink,
  Callout,
  ProsCard,
  ConsCard,
  code: Code,
  Table,
  TechCard,
}

export function CustomMDX(props) {
  return (
    &#x3C;MDXRemote
      {...props}
      components={{ ...components, ...(props.components || {}) }}
    />
  )
}

</code></pre>
<p>具体可参考往期文章：</p>
<p><a href="https://buycoffee.top/blog/tech/blog-2024">2024 Blog Refresh</a></p>
<p>在构建过程中，构建器会将 md 文件中的图片转化为自定义的图片组件。</p>
<pre><code class="language-go">![Code page (1).png](&#x3C;/blog-img/blog-2024/Code_page_(1).png>)

async function CenterImage(props: { src: string; alt: string })
</code></pre>
<p>构建器会将图片路径与图片名传入到自定义组件中，因此，我们可以在自定义组件中进行对图片的优化。</p>
<p>优化包含<strong>生成占位符</strong>，<strong>获取图片元信息</strong>与<strong>压缩优化图片</strong>三个步骤。</p>
<p>从整体来看，可以将过程简化为：</p>
<p><img src="/blog-img/image-optimization/Frame_13.png" alt="alt: 图片构建时优化过程"></p>
<h3>生成占位符</h3>
<p>我们可以采用 Plaiceholder 库来快速为图片生成占位符。</p>
<p><a href="https://plaiceholder.co/docs">Plaiceholder</a></p>
<p>在本项目中，采用提取图片颜色的方式，将颜色作为 div 背景颜色的方式作为占位符。</p>
<h3>获取图片元信息</h3>
<p>在获取占位符的同时，将图片文件的 metadata 也提取出来，其中包含了图片的宽高信息，这是避免布局偏移的核心参数。</p>
<p><img src="/blog-img/image-optimization/ray-so-export.png" alt="ray-so-export.png"></p>
<p>最终将图片信息与占位符信息作为一个对象返回到组件中。</p>
<h3>构建博客图片组件</h3>
<p>目前，我们已经获取到了图片的宽高信息以及占位符所需要的颜色信息，通过构建一个客户端的图片组件，就可以将信息组合起来，并搭配上图片的加载动画。</p>
<blockquote>
<p>动画灵感来源于：<a href="https://www.youtube.com/watch?v=7hS9b6n7HrM&#x26;t=271s">https://www.youtube.com/watch?v=7hS9b6n7HrM&#x26;t=271s</a></p>
</blockquote>
<pre><code class="language-go">const [isLoading, setIsLoading] = useState(true)

&#x3C;div
	className="absolute inset-0 rounded-lg"
	style={{ backgroundColor: hex }}
/>
&#x3C;img
	className={cn('relative h-full w-full rounded-lg border border-solid border-neutral-200 object-cover transition-all duration-500 dark:border-neutral-800 dark:brightness-75 dark:hover:brightness-100',
            isLoading ? 'opacity-0 blur-lg' : 'opacity-100 blur-0')}
	width={width}
	height={height}
	alt={alt}
	src={src}
	onLoad={() => setIsLoading(false)}
/>
</code></pre>
<p>通过一个 div 配合 backgroundColor 的设置即可为图片增加颜色占位符，而占位符的宽高则取决于图片的宽高，先前在元信息中提取到的宽高信息即可在此使用。</p>
<p>在构建时获取图片信息并设置好宽高数据，可直接避免了图片从未加载到加载后所造成的布局偏移。</p>
<p>在 Next.js 中，'next/image’ 也是通过分析静态导入图片的宽高进行提前设置，从而避免布局偏移的发生。</p>
<p><a href="https://nextjs.org/docs/app/building-your-application/optimizing/images">Optimizing: Images</a></p>
<p>在图片未加载完全时，效果为一个类似色块的占位符。</p>
<p><img src="/blog-img/image-optimization/CleanShot_2024-08-22_at_14.16.252x.png" alt="alt: 图片未加载完成状态"></p>
<p>此外，通过搭配 onLoad 函数，可以在图片加载完成后进行一些后续操作。使用 useState 在记录图片的加载状态，搭配 css 中的 transition、opacity 与 blur 参数，可以在图片完成加载后，创建一个流畅的过渡效果。</p>
<p><img src="/blog-img/image-optimization/CleanShot_2024-08-22_at_14.26.50.gif" alt="CleanShot 2024-08-22 at 14.26.50.gif"></p>
<p>完整博客图片组件参考代码如下：</p>
<p><a href="https://gist.github.com/hamster1963/5c848e47f69d049fb869e375170fc467">blog-image.tsx</a></p>
<p>至此，对于布局偏移的图片加载优化的过程就先告一段落了。</p>
<p>接下来，我们还需要对于图片大小进行优化。</p>
<h3>图片大小优化</h3>
<p>对于图片优化，有许多不同的方案，可以使用外部的优化 API，比如</p>
<ul>
<li>Vercel Image Optimization</li>
</ul>
<p><a href="https://vercel.com/docs/image-optimization">Image Optimization with Vercel</a></p>
<ul>
<li>Cloudflare Image Optimization</li>
</ul>
<p><a href="https://developers.cloudflare.com/images/">Overview | Cloudflare Image Optimization docs</a></p>
<ul>
<li>Cloudinary</li>
</ul>
<p><a href="https://cloudinary.com/">Image and Video Upload, Storage, Optimization and CDN</a></p>
<p>这些产品都提供对图片优化的 API 服务，在少量图片的情况下，选用这些产品也可快速优化图片。</p>
<p>但是对于博客站点来说，往往图片的数量并不可控，并且随着博客文章数量的增加，图片的数量也会指数级上涨，因此在本地构建时对图像进行优化，也是一种不错的方案。</p>
<p>在 Next.js 中，内置了图片优化的方式，通过 _next/image 路径即可针对图片进行优化，但对于尚存储在本地的图片来说，这种方式并不可用。</p>
<p>但其内核中， 图片优化是采用 sharp 库去进行优化图片的流程的，因此我们也可以在构建时使用 sharp 针对博客内图片进行优化。</p>
<pre><code class="language-go">const originalBuffer = await getFileBufferLocal(imagePath)
const optimizedBuffer = await sharp(originalBuffer)
      .resize(1920, 1080, { fit: 'inside', withoutEnlargement: true })
      .webp({ quality: 90, lossless: true })
      .toBuffer()
</code></pre>
<p>在代码中，我们通过读取本地图片文件，并通过 sharp 将其转化为 webp 格式（可大大降低图片大小），并通过设置 lossless 与 quality，保证图片质量不会受到太大程度的衰减。</p>
<p>在优化后，重新将其存储到 public 文件夹中，传递新的图片路径给博客图片组件。</p>
<pre><code class="language-go">// 保存优化后的图片到 public 目录
await fs.writeFile(publicPath, optimizedBuffer)
</code></pre>
<p>一切就绪，让我们开始构建吧。</p>
<p><img src="/blog-img/image-optimization/CleanShot_2024-08-22_at_15.07.242x.png" alt="CleanShot 2024-08-22 at 15.07.24@2x.png"></p>
<p>在经过几次构建后，就会发现一个问题，构建时间真的是太长了。</p>
<p>因此，我们将会针对最后一个问题：<strong>构建性能</strong>，进行优化。</p>
<h2>构建性能优化</h2>
<p>目前，没有做任何优化的情况下，每次构建中，都会针对每一张图片进行上述的优化流程</p>
<ol>
<li>获取占位符与元信息</li>
<li>生成优化后图片</li>
</ol>
<p>因此，每次构建中其实都在重复针对图片进行优化，因此，我们应该将已经优化过的图片与信息缓存起来，在下次构建时从首先从缓存中获取，不存在则再进行优化。</p>
<aside>
💡 需要注意的是，Next.js 的构建缓存路径为 **.next/cache/****
</aside>
<p>我们将提取的占位符与元信息、优化后的图片一起存储到缓存中。</p>
<p>其中，文件名采用图片路径生成的 MD5 值。</p>
<pre><code class="language-go">// 使用文件路径生成 MD5 哈希值
const contentHash = crypto.createHash('md5').update(imagePath).digest('hex')
const cacheDir = path.join(
    process.cwd(),
    '.next',
    'cache',
    'optimized-images'
  )
const placeholderCacheFileName = `${contentHash}-placeholder.json`
const placeholderCachePath = path.join(cacheDir, placeholderCacheFileName)
// 保存占位图信息到缓存
await fs.mkdir(cacheDir, { recursive: true })
await fs.writeFile(placeholderCachePath, JSON.stringify(preImage))

// 保存优化后的图片到缓存目录和 public 目录
await fs.writeFile(cachePath, optimizedBuffer)
await fs.writeFile(publicPath, optimizedBuffer)
</code></pre>
<p>在构建时，在缓存中检索到相同文件名的图片时，就可以将其写入到 public 文件夹中。</p>
<pre><code class="language-go">// 如果缓存存在，将其复制到 public 目录
await fs.mkdir(publicDir, { recursive: true })
await fs.copyFile(cachePath, publicPath)
</code></pre>
<p>完整参考代码如下：</p>
<p><a href="https://gist.github.com/hamster1963/eb264f2a8e0f391a40cda75fb27d418a">mdx.tsx</a></p>
<p>通过这种方式，可以大幅降低重复构建时所需的时间与运算性能。</p>
<p><img src="/blog-img/image-optimization/CleanShot_2024-08-22_at_15.26.302x.png" alt="CleanShot 2024-08-22 at 15.26.30@2x.png"></p>
<h2>最终效果</h2>
<p>在这个过程中，我们对图片进行了用户体验方面以及图片文件大小方面的优化。</p>
<p>在优化后的页面中，每张图片的大小约为 ～100kb 左右，且看起来质量也十分不错。</p>
<p>同时构建后的静态页面中，已嵌入的图片占位符也很好的解决了先前的布局偏移的问题。</p>
<p>欢迎在不同的博客页面中体验这一效果，同时也欢迎打开开发者工具来查看已优化图片的信息。</p>
<p><strong>Faster than fast.</strong></p>]]></description><link>https://buycoffee.top/blog/tech/image-optimization</link><guid isPermaLink="true">https://buycoffee.top/blog/tech/image-optimization</guid><dc:creator><![CDATA[Hamster1963]]></dc:creator><pubDate>Thu, 22 Aug 2024 00:00:00 GMT</pubDate><enclosure url="https://buycoffee.top/blog-img/image-optimization/cover.jpeg" length="0" type="image/jpeg"/></item><item><title><![CDATA[Next.js 14 ISR+SSG]]></title><description><![CDATA[<h2>前言</h2>
<p>现在你正在浏览的这个站点采用 Next.js14 搭配 ISR 与 SSG 进行搭建，针对首页与其他页采用不同的渲染方式，其中首页采用 ISR 的方式来同步音乐信息，在博客页面采用 SSG 的方式来预渲染博客列表与内容，在访客页面采用 SSR 的方式来实时获取访客的留言。</p>
<p>可以说使用这些不同的渲染方式最终的目的都是为了极致优化用户体验，让网站更快，更环保。（逃</p>
<p><img src="/blog-img/isr/speed.png" alt="speed.png">
<a href="https://pagespeed.web.dev/analysis/https-buycoffee-top/fju4p0lxu2?form_factor=mobile">PageSpeed</a></p>
<p>在一个框架中采用如此丰富的渲染方式，一方面体现了框架的灵活性，可以针对不同的场景进行适配，但同时，如此多样的方式也给开发者带来不小的挑战，因此这篇博客就以搭建一个博客站点为例子，尝试将这些渲染方式应用在不同的场景中。</p>
<h3>首页-ISR</h3>
<p>ISR(<strong>Incremental Static Regeneration</strong>),在 Next.js 的 Pages Router 文档中，有对 ISR 详细的介绍与使用方式，现在关于 App Router 的 ISR 先让我们留一个悬念给后文。</p>
<p><a href="https://nextjs.org/docs/pages/building-your-application/data-fetching/incremental-static-regeneration">Data Fetching: Incremental Static Regeneration (ISR)</a></p>
<p>最关键的使用方式便是将**<code>revalidate</code>** 参数添加到 <code>getStaticProps</code> 方法中，</p>
<pre><code class="language-jsx">export async function getStaticProps() {
  const res = await fetch('https://.../posts')
  const posts = await res.json()

  return {
    props: {
      posts,
    },
    // Next.js will attempt to re-generate the page:
    // - When a request comes in
    // - At most once every 10 seconds
    revalidate: 10, // In seconds
  }
}

function Blog({ posts }) {
  return (
    &#x3C;ul>
      {posts.map((post) => (
        &#x3C;li key={post.id}>{post.title}&#x3C;/li>
      ))}
    &#x3C;/ul>
  )
}
</code></pre>
<p>在 <code>getStaticProps</code> 方法中定义了**<code>revalidate</code>** 后，当 Next.js 服务端接收到新的请求后会重新构建页面，且在构建完成后会将构建（渲染）结果保存在缓存中，而对于后续的请求，如果在设置的缓存时间间隔内，则直接获取缓存中渲染好的页面并返回。（如下图）</p>
<p><img src="/blog-img/isr/Untitled.png" alt="Untitled.png"></p>
<p>这么做是有许多好处的，首先最显而易见的便是在缓存时间间隔内，相对于 SSR 每次进行渲染，ISR 明显降低了渲染页面的次数，并且是以近乎静态页面的方式去响应请求。这不仅可以缓解一些重计算的压力（读取数据库，调用第三方接口），同时也使得页面的响应速度大幅增加。</p>
<p>在了解了基础的原理后，让我们把目光回到 Next.js14+App Router 中，在 App Router 中，官方文档并没有给出特别明确的 ISR 配置文档，相反在 App Router 中则是采用缓存优先的方式去接管每一个 服务端的 fetch 请求，默认状态下都缓存了结果，而如果需要每次都是动态请求则需要手动配置 revalidate 为 0 或者 cache 为 dynamic 的方式来绕过缓存，因此在这种情况下 ISR 似乎成为了默认的配置，但如果我们不通过官方带有缓存行为的方式去获取数据，比如 kv，或者手动通过 ioredis 去获取数据，那配置 ISR 则需要一些特殊的方式。</p>
<p>在 Next.js 14 中，官方介绍了一种新的 cache 组件以供开发者手动去缓存所需的数据。</p>
<p><a href="https://nextjs.org/docs/app/api-reference/functions/unstable_cache">Functions: unstable_cache</a></p>
<blockquote>
<p><code>unstable_cache</code> allows you to cache the results of expensive operations, like database queries, and reuse them across multiple requests.</p>
</blockquote>
<p>可见<code>unstable_cache</code> 实际上就是将 ISR 中的缓存部分抽离出来，为开发者提供一种全局缓存的方式去缓存数据。</p>
<p>前文我们提到，ISR 是以近乎静态页面的速度来响应请求，是用近乎则是因为实际上在获取页面的过程中，仍需要通过读取缓存的方式去获取已经渲染完成的页面，只不过在 vercel 优秀的全球网络架构下，这种延迟被优化到近乎可以忽略。</p>
<p>我们可以通过 vercel 的仪表盘日志来查看这个过程。</p>
<p><img src="/blog-img/isr/Untitled%201.png" alt="Untitled"></p>
<p>使用<code>unstable_cache</code> 十分简单，将需要缓存的结果包裹起来即可。</p>
<pre><code class="language-jsx">import { unstable_cache as cache } from 'next/cache'
import { kv } from '@vercel/kv'

const cacheGetNowPlaying = cache(
  async () => {
    return (await kv.get) &#x3C; any > 'live:mac-music-now'
  },
  ['mac-music-now'],
  {
    revalidate: 10,
    tags: ['music'],
  }
)
</code></pre>
<p>同时在第三个参数中可以针对 revalidate 与 tags（缓存键）进行更加细化的配置。</p>
<p>使用 cache 后的数据也十分简单。</p>
<pre><code class="language-jsx">export default async function NowPlaying() {
  const data = await cacheGetNowPlaying();
  return ...
}
</code></pre>
<p>这下，ISR 便在 App Router 中配置好了，但同时目前的配置也带来了不可忽视的问题:</p>
<ol>
<li>在缓存时间间隔内，无论源数据是否有更新都无法触发新的页面渲染。</li>
<li>在缓存过期后，第一个触发 ISR 的请求会因为触发服务器重新渲染的原因，而处于很长的等待服务器时间，造成不好的用户体验。</li>
</ol>
<p>因此可以采用手动进行缓存过期管理与 ISR 预热的方式来优化用户体验。</p>
<pre><code class="language-jsx">const cacheGetNowPlaying = cache(
  async () => {
    return (await kv.get) &#x3C; any > 'live:mac-music-now'
  },
  ['mac-music-now'],
  {
    revalidate: false,
    tags: ['music'],
  }
)
</code></pre>
<p>在配置项中，将 revalidate 设为 false，这会将缓存的时间设置为无限长（其实大概是 1 年），同时在 tags 中配置一个独一无二属于这个缓存的 key，这两部分的配置可以解释为不进行缓存过期，只有通过某种方式才可以触发缓存过期。而这种方式就是通过 <code>next/cache</code> 中的<code>revalidateTag</code> 来针对 tag 进行缓存过期。</p>
<p>因此可以在源数据更新后通过触发 App Router 中定义的 API 来进行缓存过期。</p>
<pre><code class="language-jsx">import { revalidateTag } from 'next/cache';

export async function POST(req: NextRequest) {
  try {
    revalidateTag('music');
    return NextResponse.json({ message: 'success' }, { status: 201 });
  } catch (error) {
    return NextResponse.json({ error }, { status: 400 });
  }
}
</code></pre>
<p>那缓存刷新也十分简单，只需要在触发缓存过期后直接请求对应的站点，Next.js 就会重新构建页面并缓存。</p>
<p>以 Rust 为例。</p>
<pre><code class="language-rust">trace!("开始触发 ISR");
let isr_url = "https://buycoffee.top/";
let client = Client::new();
let response = client
    .get(isr_url)
    .header("User-Agent", "rust-cron-app")
    .send()
    .await?;
if response.status().is_success() {
    info!("触发 ISR 成功: {}", response.status());
    } else {
    info!("触发 ISR 失败: {}", response.status());
}
</code></pre>
<p><img src="/blog-img/isr/CleanShot_2024-05-20_at_23.05.242x.png" alt="CleanShot 2024-05-20 at 23.05.24@2x.png"></p>
<p>通过这种手动控制的方式，不仅可以避免了 ISR 中初次访问较慢的问题，同时也可以更加精确地控制缓存。搭配 loading.tsx 的使用,更是可以使得用户可以无感切换到新构建页面而无需等待加载阻塞。</p>
<p><img src="/blog-img/isr/Untitled%202.png" alt="Untitled"></p>
<p>这是一个切换的 Demo。</p>
<video width="100%" class="rounded-xl" autoplay loop muted playsinline>
  <source src="https://pub-85fe3948f0644e2cba137d74f3630b8b.r2.dev/CleanShot%202024-05-20%20at%2022.28.09.mp4" type="video/mp4">
</video>
<h2>博客页-SSG</h2>
<p>而对于一些不太依赖动态数据，且布局一致的动态路径，SSG 便可以派上用场了。</p>
<p>SSG**(Static Site Generation)**</p>
<p><a href="https://nextjs.org/docs/pages/building-your-application/rendering/static-site-generation">Rendering: Static Site Generation (SSG)</a></p>
<p>以站点中的博客为例，博客的路径是动态的 [slug]：</p>
<p><img src="/blog-img/isr/Untitled%203.png" alt="Untitled"></p>
<p>但博客的内容实际上都是静态存储在文件中，通过 mdx 渲染成 HTML 的。</p>
<p><img src="/blog-img/isr/ray-so-export.png" alt="ray-so-export.png"></p>
<p>如果不使用 SSG 在编译阶段对 mdx 进行静态渲染，则 Next.js 会在接收到请求后再进行服务端渲染，而对于这种几乎不会变更的内容，使用 SSG 在构建阶段渲染则可以将动态的路径预先渲染出来，在用户请求时就可以直接返回静态页面。</p>
<p>在 Next14 App Router 中，对于静态路径，使用 <code>generateStaticParams</code> 方法便可以使用 SSG。</p>
<pre><code class="language-rust">export async function generateStaticParams() {
  let getPost = getBlogPosts();
  getPost = getPost.filter((post) => post.metadata.category !== 'Daily');
  return getPost.map((post) => ({
    slug: post.slug,
  }));
}
export default async function Page({ params }) {
  const { slug } = params;
  return (
    &#x3C;section className="sm:px-14 sm:pt-6">
      &#x3C;BlogContent slug={slug} />
    &#x3C;/section>
  );
}
</code></pre>
<p>在 <em><code>generateStaticParams</code></em> 方法中进行全部动态路径的获取，并将其整体作为返回值，在页面中使用 <code>params</code> 参数进行路径的接收与使用，通过这种方式，Next.js 便会在构建时执行方法并渲染获取到的路径内页面。</p>
<p><img src="/blog-img/isr/ray-so-export_(2).png" alt="ray-so-export (2).png"></p>
<h2>访客页-SSR</h2>
<p>在访客页面，在每次请求页面时，服务器读取数据库获取最新的留言进行显示，因此采用 SSR 搭配 loading.tsx 的方式进行构建。</p>
<p><img src="/blog-img/isr/ray-so-export_(4).png" alt="ray-so-export (4).png"></p>
<p>SSR 大家都不陌生，因此在这就不做过多的介绍，重点是在于如何让用户的等待体验更好，骨架屏往往比一个旋转的图标更好，因此在 Next.js 中可以通过在 page.tsx 路径下增加 loading.tsx， 来使得页面在 SSR 期间也可以在客户端上显示一些所需的信息。</p>
<p><a href="https://nextjs.org/docs/app/building-your-application/routing/loading-ui-and-streaming">Routing: Loading UI and Streaming</a></p>
<p><img src="/blog-img/isr/Untitled%204.png" alt="Untitled"></p>
<p>请求页面时，loading 内定义的组件会立即显示在客户端页面上，避免了阻塞等待所带来的糟糕用户体验。</p>
<p>同时，在用户提交了留言后，应使用 revalidatePath 方法使页面重新渲染，以获取最新的留言信息。</p>
<p><img src="/blog-img/isr/ray-so-export_(5).png" alt="ray-so-export (5).png"></p>
<p>下面是一个 Demo ：</p>
<video width="100%" class="rounded-xl" autoplay loop muted playsinline>
  <source src="https://pub-85fe3948f0644e2cba137d74f3630b8b.r2.dev/CleanShot%202024-05-21%20at%2020.31.52.mp4" type="video/mp4">
</video>
<h2>总结</h2>
<p>通过在站点中使用不同的渲染方式，不仅可以很好的应对了不同的场景，同时也使得站点具有良好的用户体验，在具体的开发中可以多去尝试不同的方式，感受不同渲染方式所带来的开发体验与用户体验，构建 UI/UX 良好的站点。</p>
<p>Hope u enjoy.</p>]]></description><link>https://buycoffee.top/blog/tech/isr</link><guid isPermaLink="true">https://buycoffee.top/blog/tech/isr</guid><dc:creator><![CDATA[Hamster1963]]></dc:creator><pubDate>Mon, 20 May 2024 00:00:00 GMT</pubDate><enclosure url="https://buycoffee.top/blog-img/isr/cover.jpeg" length="0" type="image/jpeg"/></item><item><title><![CDATA[为 HomeDash 构建实时巴士]]></title><description><![CDATA[<p>大家好久不见，一段时间没有写博客了，那今天就简单写写，介绍一下最近又在 HomeDash折腾了什么吧。</p>
<h2>关于 HomeDash</h2>
<h3>接触 Next.js</h3>
<p>在 2023 年中的时候，我开始接触 Next.js，当时的想法便是</p>
<blockquote>
<p>使用 Next.js 构建属于自己的 Web 应用</p>
</blockquote>
<p>在刚开始学习与折腾 Next.js 的时候，也尝试过不少的UI组件，比如</p>
<ul>
<li><a href="https://www.radix-ui.com/">https://www.radix-ui.com</a></li>
<li><a href="https://tdesign.tencent.com/">https://tdesign.tencent.com</a></li>
<li><a href="https://semi.design/zh-CN">https://semi.design/zh-CN</a></li>
</ul>
<p>也构建了一些小的练手应用，比如公司的点餐系统与后台监控系统。</p>
<p><img src="/blog-img/live-bus/CleanShot_2025-08-04_at_11.25.542x.png" alt="alt:公司设备后台管理"></p>
<p>在某一天，我想: 为什么不构建一个专属于自己的监控仪表盘呢？</p>
<h2>HomeDash v0.0.1</h2>
<h3>Backend</h3>
<p>监控仪表盘最核心的便是数据，因此我采用了 Go 作为数据后端，使用 gcron 作为定时任务组件，设置不同的定时任务从定义好的数据源进行数据的拉取。</p>
<p><img src="/blog-img/live-bus/CleanShot_2025-10-12_at_23.57.062x.png" alt="CleanShot 2025-10-12 at 23.57.06@2x.png"></p>
<p>就是这样简单的定时拉取，将数据通过各种 API 或者逆向 SDK 的方式收集到后端后，存储到 Redis 中，并通过 mqtt 广播出去，就完成了所需数据的处理过程。</p>
<p><img src="/blog-img/live-bus/Frame_17-new.png" alt="Frame 17.png"></p>
<p>零零散散收集到目前有大约 20 个数据源通过定时任务的方式拉取。</p>
<h3>Frontend</h3>
<p>对于后端顺利的开发过程来说，第一个版本的 homedash 前端构建可以算是一团糟。</p>
<p>当时的我对于前端框架并不熟悉，整个人完全沉浸在 React 的各种概念与组件库中，对项目架构和前端规范的了解更是一张白纸，有的只是对前端新鲜概念的一腔热血，并且当时 AI 也没有如今各种的 Claude Code 或者 Codex 这种超强 CLI 对于编程革命性的改变。</p>
<p>因此基本上， HomeDash v0.0.1 真的是采用了古法编程，一点点阅读文档与学习代码后纯人肉编写的。</p>
<p>而构建的过程中最让人抓狂也是兴奋的就是 UI 的设计与构建。</p>
<p>在框架的选择上，一如既往的选择了 Next.js。</p>
<p><img src="/blog-img/live-bus/CleanShot_2025-10-13_at_00.44.332x.png" alt="CleanShot 2025-10-13 at 00.44.33@2x.png"></p>
<p>虽然在 2025 年的今天看来，Next.js 因其多变的 API 与复杂的缓存模型带来的心智负担让许多开发者诟病，但是不可否认的是，Next.js 仍然是最热门，社区内容最丰富的框架之一，而且最关键的是 AI 对于 Next.js 的支持非常好，现在选择 Next.js 也仍然是不错的选择。</p>
<p>而在组件库的选择上，沿用了当时在公司项目中采用的字节跳动出品的前端组件库-Semi Design</p>
<ul>
<li><a href="https://semi.design/zh-CN">https://semi.design/zh-CN</a></li>
</ul>
<p>当时对组件库并没有二次开发的想法，总是凭感觉选择最合适的组件一点点拼凑出仪表盘的样子。</p>
<p>前后大约花了一周的时间，完成了 HomeDash 第一个版本的构建。</p>
<p><img src="/blog-img/live-bus/CleanShot_2025-10-13_at_00.19.452x.png" alt="CleanShot 2025-10-13 at 00.19.45@2x.png"></p>
<p>在整个构建的过程中，都是以开源的形式进行的，竟然真的收到了一些朋友的关注。</p>
<p><a href="https://buycoffee.top/blog/tech/github-star">100个 GitHub Star</a></p>
<h3>构建后的反思</h3>
<p>在构建完成后，首先当然对于 React 和各类组件库的用法更加熟悉了，而另一方面对于 UI 的控制与页面性能也有了许多的思考。</p>
<p>对于 Semi Design ，想要在原有的样式上进行修改是很困难的事情，虽然也有一些导出的属性与方法可以控制，但是还是不如直接在组件代码上修改来的直接，而这也导致了许多冗余的代码可能只是为了实现一个小样式。</p>
<p>而对于性能来说，Semi Design 的架构使得编译时压缩的效果并不好，许多没有用到的组件与依赖都被打包进了最后的成品中，使得 First Load JS 的大小非常大，而对于一个简单的仪表盘来说这些都大大影响了使用的感受。</p>
<p>最后让我弃用的原因则是 Semi Design 对于服务端渲染的支持并不太友好，支持 SSG 而对 SSR 的支持很薄弱，这就导致在使用例如 Remix 或者 Next.js 这类在运行时渲染的框架来说会出现很多问题。</p>
<p>因此在构建完 HomeDash v0.0.1半年后，在新旧工作短短一个月的喘息期内，我重新开始了心目中所向往的 HomeDash 的构建。</p>
<h2>HomeDash V1</h2>
<h3>后端改造</h3>
<p>对于后端来说，需要修改的地方并不多，最终只是重新实现了 SSE 与获取默认数据的架构，可以通过一个接口方便地以单次或者流式的方式获取最新的数据。</p>
<h3>前端的重新构想</h3>
<p>在开始构建时，我重新梳理了一下心中的目标：</p>
<ul>
<li>UI 好看</li>
<li>适配桌面端与移动端</li>
<li>性能好</li>
<li>可以在无JS的浏览器中使用</li>
</ul>
<p>设立好这几个目标后，便是重新进行架构选择的过程。</p>
<p>这时一套在开源社区很火的组合让我很是兴奋。</p>
<blockquote>
<p>Next.js + Tailwind CSS + Shadcn/ui</p>
</blockquote>
<p>这套组合几乎完美满足了我的需求，让我完全拥有了对组件的掌控，同时这套组合在性能与服务端渲染上也相当优秀。</p>
<p>于是便吭哧吭哧开始了全新版本的构建。</p>
<h3>全新的 UI 与数据获取架构</h3>
<p>这次在 UI 的设计上，从 v0.0.1 版本中总结了许多对于设计的思考与对好设计的模仿与学习。</p>
<p>一切都从最快获取到有效信息的角度出发，以整体一致的 UI 带来轻松的用户体验。</p>
<p><img src="/blog-img/live-bus/CleanShot_2025-10-13_at_01.24.432x.png" alt="alt:全新设计的 HomeDash"></p>
<p>在数据架构上，为了实现一打开页面就已经有数据填充而无需等待 JS 的加载与客户端数据获取的过程，采用了 Server Fetch 配合 ISR 来实现这个愉悦的使用体验。</p>
<p><a href="https://home.buycoffee.top/">HomeDash</a></p>
<p>当然你可以试试在浏览器中刷新来感受一下。</p>
<p>首先在定义获取数据方法时，通过设置 revalidate 参数让服务端缓存已经获取好的数据，设置为 1 的目的是启用 ISR 并保证数据的实时性。</p>
<p><img src="/blog-img/live-bus/ray-so-export-2.png" alt="alt:缓存参数设置"></p>
<p>在组件获取数据时，在服务端组件通过使用 SWRConfig 的 fallback 让服务端优先获取缓存好的数据后，再在后台更新缓存。</p>
<p><img src="/blog-img/live-bus/ray-so-export-3.png" alt="ray-so-export-3.png"></p>
<p>而在客户端，相同的，使用上面定义好的 fetch 方法绑定到 SWR 实例中，并且名称与 SWRConfig 中定义的一致，客户端在获取数据时就会先将服务端返回的数据作为填充，再进行后续的数据请求，以达到一打开页面就可以迅速查看到数据的目的。</p>
<p><img src="/blog-img/live-bus/ray-so-export-4.png" alt="ray-so-export-4.png"></p>
<p>这就是 V1 比较有意思的构建过程了，过程中还使用了最新的 <strong>View Transition API</strong> 与 <a href="https://www.figma.com/community/plugin/1362669306042132547/progressive-blur">Progressive Blur</a> 来丰富页面的交互与视觉效果。</p>
<h2>实时公交的构建</h2>
<h3>繁琐的小程序</h3>
<p>朋友上下班的过程中，总是需要查看实时公交的信息，而每次查看都很麻烦。</p>
<ul>
<li>点开微信</li>
<li>点开小程序</li>
<li>点开想要查看的公交车</li>
</ul>
<p>经过这几步可能还看不到，还要被广告拦一层，最后车都过站了还没看到公交信息。</p>
<h3>实时公交数据获取</h3>
<p>既然如此，那不如在HomeDash中构建一个实时公交的页面，实时获取特定公交的信息并显示，使得打开页面就可以方便地查看到公交的信息而不用通过繁琐的步骤。</p>
<p>在实时公交的 API 选择上，其实有蛮多免费的选择，比如：</p>
<ul>
<li>
<p><a href="https://api.lolimi.cn/doc/doc-api_tdhz.html">桑帛云API - 免费API</a></p>
</li>
<li>
<p><a href="http://bus.wxbus163.cn/app/index.php?i=1&#x26;c=entry&#x26;do=index&#x26;m=mon_yjgz">全国实时公交api接口开放平台</a></p>
</li>
</ul>
<p>数据的来源不同，准确性也会有蛮大的差异。</p>
<p>HomeDash实时公交的API采用的是车来了的API，以获取较为准确的数据。</p>
<p>请求的基础地址为：</p>
<pre><code class="language-go">https://web.chelaile.net.cn/api/bus/line!encryptedLineDetail.action
</code></pre>
<p>关键是通过这 5 个参数去获取到实时的公交信息。</p>
<p><img src="/blog-img/live-bus/ray-so-export.png" alt="ray-so-export.png"></p>
<p>在成功获取到数据并解密后，通过解析其中的JSON块来获取最终的实时数据。</p>
<p><img src="/blog-img/live-bus/ray-so-export-2%201.png" alt="ray-so-export-2.png"></p>
<p>最终将数据整理一下，完成各个指定公交车的实时数据获取。</p>
<pre><code class="language-json">{
    "bus_list": [
        {
            "busId": "343006",
            "endSn": "黄埔东路（石化南路）总站",
            "lines": "020-05810-0",
            "price": "未知",
            "reachtime": "2025-10-13 17:15",
            "surplus": "10站",
            "travelTime": "27分",
            "distanceToWaitStn": 8154,
            "speed": 0.1
        },
        {
            "busId": "342884",
            "endSn": "黄埔东路（石化南路）总站",
            "lines": "020-05810-0",
            "price": "未知",
            "reachtime": "2025-10-13 17:05",
            "surplus": "6站",
            "travelTime": "17分",
            "distanceToWaitStn": 5385,
            "speed": 5
        }
    ]
}
</code></pre>
<h3>前端UI构建</h3>
<p>在UI的构建上，如何设计成一眼就可以清晰了解当前的公交状态是最核心的目的。</p>
<p>因此将实时公交卡片设计为两个板块，上面的是公交车的基本信息，加上最近一辆车的状态，在右侧标注公交的方向。</p>
<p>而在下方的公交线中划分为两块区域，在百分之70%的地方为指定的公交站点，左侧为正在前往站点的公交车信息，右侧则显示最近一班过站的信息。</p>
<p><img src="/blog-img/live-bus/CleanShot_2025-10-13_at_16.58.122x.png" alt="CleanShot 2025-10-13 at 16.58.12@2x.png"></p>
<p>这样子在查看某一路的公交车的时候，便可以清晰地快速了解线路上有用公交的信息，更好的留出时间为出行做准备。</p>
<p><img src="/blog-img/live-bus/IMG_0256.jpeg" alt="IMG_0256.jpeg"></p>
<p>最后搭配一些天气信息与降雨信息，上班或者出行前看一眼页面，就可以很轻松地根据天气和公交安排好出行的计划。</p>
<p>这就是 HomeDash 的粗略介绍和实时公交的构建过程，希望在未来继续构建出好看又好用的Feature！</p>
<p>谢谢你的阅读。</p>]]></description><link>https://buycoffee.top/blog/tech/live-bus</link><guid isPermaLink="true">https://buycoffee.top/blog/tech/live-bus</guid><dc:creator><![CDATA[Hamster1963]]></dc:creator><pubDate>Tue, 14 Oct 2025 00:00:00 GMT</pubDate><enclosure url="https://buycoffee.top/blog-img/live-bus/cover.jpeg" length="0" type="image/jpeg"/></item><item><title><![CDATA[我的工具们(s)]]></title><description><![CDATA[<p>终于要来聊聊我的日常工具了，我想要聊一些这几年使用 macOS 下来遇到的好用的，我每天都在用的软件。</p>
<p>我主要从生活和工作方面来聊聊现在安装在我的 MacBook Air 上的软件。</p>
<h2>我的 Dock 栏</h2>
<p><img src="/blog-img/my-tools/Untitled.png" alt="Untitled"></p>
<p>这就是我日常基础的 Dock 栏的状态，都是日常中普通的不能再普通的软件，常日开着的微信和钉钉，加上和朋友聊天的 Message。</p>
<h2>Things 3</h2>
<p>首先我先聊聊的是一款叫做 Things 的日程管理软件，对于拖延症晚期的我来说，已经数不清用过多少个待办清单，从 <strong>滴答清单</strong> 到 <strong>OmniFocus</strong> ,多少次下载后用了两周就又默默删除了软件，导致不下去的原因主要有两个:</p>
<ol>
<li>软件界面设置过于复杂，在开始使用软件的时候需要投入大量的学习成本。</li>
<li>太多无关功能，使用起来总会被无关的功能打扰。</li>
</ol>
<p>后面我就想找到一款，简单的日程清单软件，最好可以多端同步，界面足够简约，于是便找到了这款时常会在各类待办清单软件推荐榜单上的 Things 3。</p>
<p><img src="/blog-img/my-tools/Untitled%201.png" alt="Untitled"></p>
<p>一路使用下来，如今已经变成了我每天都会去使用的清单软件，Things 3 完美的符合了我的需求，左侧为简单的待办分区，可以通过时间与项目分别去查看不同的待办，但通常我都会以简单出发，将最近需要的事都铺到 Today 分区中，再分到不同的项目中，这样子我一打开软件便可以清晰的看到近期的全部安排。</p>
<p>同时，可以简单设置一些 DDL 用来提醒自己，让自己对某些事更加关注些。</p>
<p><img src="/blog-img/my-tools/Untitled%202.png" alt="Untitled"></p>
<h2>Notion</h2>
<p>关于 notion, 自从疫情期间切换到 macOS 后，第一个认识的笔记软件便是 Notion 。</p>
<p>想一想 notion 已经陪伴了我快4年的时间，这四年间无论是大学的资料还是出来实习工作的学习笔记，都满满当当的记录在了 notion 中，同时还有自己特别定制的使用场景，比如订阅管理和博客文章的记录中心。</p>
<p><img src="/blog-img/my-tools/Untitled%203.png" alt="Untitled"></p>
<p>选择 notion 的原因也很简单，外观足够简洁，同时对各种格式的文本与图片支持都很好，最关键的是可以快速通过网页链接的形式将内容分享出去，与朋友之间互相交换一些资料也十分方便。</p>
<p>能让我彻底留在 notion 生态的功能是 notion 强大的数据库。</p>
<p><img src="/blog-img/my-tools/Untitled%204.png" alt="Untitled"></p>
<p>搭配数据库与函数，可以创造几乎是无限可能的个人数据中心，打破了传统笔记软件仅仅对表格的简单支持，在notion的数据库中可以无限嵌套任何你想要存入的数据，甚至可以在数据库中再嵌套数据库，在一开始可能会有一个学习的过程，但一旦把数据库运用到个人工作或者生活中那 notion 还是目前市面上那独一无二没有替代品的笔记软件。</p>
<p>目前最大的问题则是 notion 的服务方式，在 notion 记录的全部数据实则都保存在云上，没有本地优先的选项也意味着一旦 notion 在未来经营不善或有突发意外，作为使用者的我们很难去将我们的个人数据按照现在的格式完整导出，但目前来看 notion 的发展轨迹还算平稳，有着亚马逊云在背后的撑腰，短期来看不会有这方面的问题。</p>
<h2>Apple Music</h2>
<p>音乐可以说是我的燃料桶，无论何时何地，只要有音乐，我便会觉得生活还没那么糟，还有很多美好在等待着发生，因此我的日常软件最重要的便是自带的音乐软件。</p>
<p>Apple Music 的订阅费用并不贵，个人一个月11元，家庭17元可以和5个人一起分享，如果是大学生的话每月只需5月就可以享受无限量的在线音乐。</p>
<p><img src="/blog-img/my-tools/Untitled%205.png" alt="Untitled"></p>
<p>Apple Music的资料库的形式让许多人又爱又恨，它实际上是延续了以前旧 iTunes 时期的使用习惯，一次性付费购买单曲或专辑后，歌曲就会存入到资料库中，这就与现在绝大多数的在线音乐平台采用的喜爱歌曲逻辑不一样，因此许多人并不适应这一种方式。</p>
<p>但对于我而言，这正是吸引我去使用Apple Music的原因，听到一首歌曲后，不是简单的喜爱，而是会思考一下是否值得加入资料库中再去仔细品味，这种方式下久而久之，资料库就全部是超过平均喜欢水平的歌曲，无论点开哪一首，都可以很好的去聆听和感受音乐，同时在音乐推荐上的也会听到更多喜爱的歌曲。</p>
<p>同时，简约的播放器组件和精美的歌词展示也是我喜爱Apple Music的很大原因。</p>
<p><img src="/blog-img/my-tools/Untitled%206.png" alt="Untitled"></p>
<h2>CleanShot X</h2>
<p>在 macOS 上，有那么一款截图软件，几乎是每个科技博主，或者内容分享者会进行购买的，那便是大名鼎鼎的 CleanShot X。</p>
<p><img src="/blog-img/my-tools/Untitled%207.png" alt="Untitled"></p>
<p>截图并不难，但是对截图的自定义常常是许多软件缺失的部份，这篇文章的全部截图都是由这个软件进行截图并编辑的，无论是加上背景，加上文字或指示，他都可以几乎完美的完成你的需求。</p>
<p><img src="/blog-img/my-tools/Untitled%208.png" alt="Untitled"></p>
<p>软件的无敌稳定性与丰富的自定义程度使得在试用一次之后就迅速购买了它。</p>
<h2>Klack</h2>
<p>接下来这款软件就有点意思了，他是一款模拟机械键盘敲击声音的软件。</p>
<p>你没听错，就是一款简单的模拟机械键盘声音的运行在后台的软件。</p>
<p><img src="/blog-img/my-tools/Untitled%209.png" alt="Untitled"></p>
<p>这款需要花费 28 元的软件提供了三种键盘的声音，在每次键盘敲击的时候，都会很好的模拟出按下与松开的声音，由于程序作者都是采用真实的键盘录制声，甚至在录制的时候还有相对应的空间位置效果，因此效果的评价就一个字：真实。比我自己听到我键盘的声音还真实。</p>
<p><img src="/blog-img/my-tools/Untitled%2010.png" alt="Untitled"></p>
<p>可惜软件在4月的一次更新后就再也没更新过，作者先前在推特承诺说1.3版本将会加入更多的声音与选项，如今快一年过去了，希望软件的下次更新真的会到来。</p>
<p><img src="/blog-img/my-tools/Untitled%2011.png" alt="Untitled"></p>
<h2>Timemator</h2>
<p>最后一个软件是一个思路与以往的专注记录软件思路截然不同的软件，往往专注记录的软件都采用主动开始的方式去进行，需要使用者主动去记录所做的事情与时间。但 timemator 采用设置自动记录的方式，在后台默默帮你记录。</p>
<p><img src="/blog-img/my-tools/Untitled%2012.png" alt="Untitled"></p>
<p>通过设置自动化的触发流程，比如打开某个软件，停留在某个网站，timemator就会帮你开始记录。</p>
<p><img src="/blog-img/my-tools/Untitled%2013.png" alt="Untitled"></p>
<p>这样子通过设置一些自己的记录项，即可毫无心智负担的让它去帮助你记录属于自己的专注记录表。</p>
<p><img src="/blog-img/my-tools/Untitled%2014.png" alt="Untitled"></p>
<p>这就是我的日常工具</p>]]></description><link>https://buycoffee.top/blog/tech/my-tools</link><guid isPermaLink="true">https://buycoffee.top/blog/tech/my-tools</guid><dc:creator><![CDATA[Hamster1963]]></dc:creator><pubDate>Fri, 05 Jan 2024 00:00:00 GMT</pubDate><enclosure url="https://buycoffee.top/blog-img/my-tools/cover.jpeg" length="0" type="image/jpeg"/></item><item><title><![CDATA[加速多架构 Next.js 镜像构建]]></title><description><![CDATA[<h2>前言</h2>
<p>近期，开源项目 nezha-dash 引入了 Docker 部署方式。为了兼容 <strong>linux-arm64</strong> 架构，项目被打包成多架构镜像并发布到了 Docker Hub。</p>
<p>在此过程中，通过不断改进配置和工具链，显著提升了打包 Next.js 多架构镜像的效率。本文将简单分享这一优化过程。</p>
<h2>Next.js 打包</h2>
<p>在 Next.js 中，在配置文件（例如：next.config.mjs ）中，将 <strong>output</strong> 配置项设置为 <strong>standalone</strong> ，即可打包成可使用 node 进行启动的独立前端服务。</p>
<p><img src="/blog-img/nextjs-docker/ray-so-export.png" alt="ray-so-export.png"></p>
<p>因此，将 Next.js 打包成镜像的步骤也十分简单，只需要将 Next.js 构建后的文件放到包含 node 环境的镜像内，设置好启动命令即可完成镜像打包。</p>
<h2>官方示例</h2>
<p>在 Next.js 的官方文档中，提供了一个 Dockerfile 示例。</p>
<p><img src="/blog-img/nextjs-docker/Frame_16.png" alt="alt: 由于步骤较为繁琐冗长，因此进行了模糊化处理。"></p>
<p>在官方的示例中，采用了分布构建的方式，将构建分为四步：</p>
<ol>
<li>安装构建所需系统内核库</li>
<li>安装项目依赖</li>
<li>构建项目</li>
<li>将构建后的文件与静态文件放入运行镜像中，配置启动命令。</li>
</ol>
<p>官方示例中采用 node:18-alpine 作为基础镜像，分别进行上述的四个步骤，而其中对于构建性能的优化并没有特别完善。</p>
<ul>
<li>由于 alpine 的精简化，在构建前需要安装系统依赖库</li>
<li>在包安装上，也许会花费许多时间</li>
</ul>
<p>首先，我们针对包管理器与基础镜像这两个模块进行优化。</p>
<h2>使用Bun进行加速</h2>
<p>在项目中，采用 Bun 作为包管理器，关于 Bun 的介绍，可查看：</p>
<p><a href="https://bun.sh/">Bun — A fast all-in-one JavaScript runtime</a></p>
<p>通过 Bun 来安装与管理项目中的包，不仅可以以极快的速度安装项目依赖，还可以配合官方的 oven/bun 镜像来加速 Docker 构建。</p>
<p>以下将采用 Bun 作为工具，逐步优化镜像打包性能与镜像大小。</p>
<h3>1. 使用 Bun 作为包管理器与基础容器</h3>
<pre><code class="language-docker">FROM oven/bun:1 AS base

# Stage 1: Install dependencies
FROM base AS deps
WORKDIR /app
COPY package.json bun.lockb ./
RUN bun install --frozen-lockfile

# Stage 2: Build the application
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN bun run build

# Stage 3: Production image
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production

COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static

EXPOSE 3000
CMD ["bun", "run", "server.js"]
</code></pre>
<p>在 Dockerfile 中，将 oven/bun:1 作为我们的基础镜像，分布进行构建任务：</p>
<ol>
<li>使用 Bun 安装依赖</li>
<li>使用 bun run build 指令（定义在 package.json 中）构建 Next.js 服务</li>
<li>将构建好的文件与静态文件放入运行镜像，定义启动命令</li>
</ol>
<p>相对于官方的示例，不仅步骤简洁了不少，无需手动安装额外的系统依赖。</p>
<p><img src="/blog-img/nextjs-docker/%E6%97%A0%E6%A0%87%E9%A2%98-2024-10-23-2226.png" alt="无标题-2024-10-23-2226.png"></p>
<p>图上方是pnpm安装包所需时间，下方是bun安装包所需时间，可以看到，使用Bun作为包管理器后，在包安装的速度上也有很大提升。</p>
<h3>2.优化最终镜像大小</h3>
<p>在使用 Bun 打包镜像后，对于先前的镜像，我们会发现镜像大小显著地增加了，从先前的 67M 膨胀到了目前的 109 M，因此，我们需要对最终的运行镜像大小进行优化。</p>
<p><img src="/blog-img/nextjs-docker/CleanShot_2024-10-25_at_09.18.112x.png" alt="CleanShot 2024-10-25 at 09.18.11@2x.png"></p>
<p>在官方的示例中，采用了 alpine 系统作为基础镜像，同样的，我们可以保留 oven/bun 作为依赖安装与构建镜像，而将运行镜像改为更加精简的 oven/bun:alpine 。</p>
<pre><code class="language-docker">FROM oven/bun:1 AS base

# Stage 1: Install dependencies
FROM base AS deps
WORKDIR /app
COPY package.json bun.lockb ./
RUN bun install --frozen-lockfile

# Stage 2: Build the application
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN bun run build

# Stage 3: Production image
FROM oven/bun:1-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static

EXPOSE 3000
CMD ["bun", "run", "server.js"]

</code></pre>
<p>可以看到，重新构建后，镜像大小回到了先前最初的大小。</p>
<p><img src="/blog-img/nextjs-docker/CleanShot_2024-10-25_at_09.19.062x.png" alt="CleanShot 2024-10-25 at 09.19.06@2x.png"></p>
<h2>使用统一构建进行加速多架构镜像</h2>
<h3>多架构镜像</h3>
<p>随着 Arm 设备近几年的不断发展，多架构支持的镜像也是十分必要的，在 Arm 设备上虽然也可以运行 Amd64 架构的镜像，但难免在性能上会收到影响。</p>
<p>因此，在发布打包镜像时，可以将 Amd64 与 Amd64 架构放在同一个镜像仓库中，作为多架构镜像进行分发。</p>
<p>在项目中采用 GitHub Actions，通过 git tag 的方式进行触发打包。</p>
<pre><code class="language-yaml">name: Build and push Docker image

on:
  push:
    tags:
      - 'v*'

env:
  REGISTRY_IMAGE: hamster1963/nezha-dash
  ALIYUN_REGISTRY_IMAGE: registry.cn-guangzhou.aliyuncs.com/hamster-home/nezha-dash

jobs:
  build-and-push:
    name: Build and push Docker image
    runs-on: ubuntu-latest
    environment: Production
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
        with:
          driver-opts: network=host

      - name: Login to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Login to AliYun Container Registry
        uses: docker/login-action@v3
        with:
          registry: registry.cn-guangzhou.aliyuncs.com
          username: ${{ secrets.ALI_USERNAME }}
          password: ${{ secrets.ALI_TOKEN }}

      - name: Extract metadata (tags, labels) for Docker
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: |
            ${{ env.REGISTRY_IMAGE }}
            ${{ env.ALIYUN_REGISTRY_IMAGE }}
          tags: |
            type=raw,value=latest
            type=ref,event=tag

      - name: Build and push Docker image
        uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
</code></pre>
<p>在 Build and push Docker image 任务中，我们使用 docker/build-push-action@v6 作为工作流工具，在 platforms 参数中，可以很方便地将目标架构传入，Docker 会为我们自动构建这些架构的镜像。</p>
<h3>构建速度问题</h3>
<p>在首次构建后，会发现，在构建 linux/amd64 架构的镜像时，速度很快，大约 50 秒就可以完成构建，但是构建 linux/arm64 架构镜像时却花费了惊人的 10 分钟，这其中一定发生了什么。</p>
<p><img src="/blog-img/nextjs-docker/CleanShot_2024-10-25_at_09.30.192x.png" alt="CleanShot 2024-10-25 at 09.30.19@2x.png"></p>
<p>实际上，linux/arm64 构建速度如此慢的原因是由于仿真模拟的性能损耗。</p>
<h3>仿真模拟与交叉编译</h3>
<p><img src="/blog-img/nextjs-docker/image.png" alt="image.png"></p>
<p>在目前我们编写的 Dockerfile 下，在为 linux/arm64 架构构建镜像时，是完全采用仿真编译的方式进行的，意味着构建过程需要在架构之间不断转换它们的指令，因此构建性能会收到很大的影响。</p>
<p>因此我们可以朝着交叉编译的方向进行优化，将全部的构建过程放在宿主机的架构上，这样子无需仿真，性能也不会受到太大的影响。</p>
<h3>Next.js 交叉编译</h3>
<p>对于 Next.js 而言，编译的过程实际上是一系列优化和转译，如果项目使用了 Typescript，则转换为最终的 Javascript ，并进行各种的混淆，压缩。</p>
<p>对于最终产物，由于 Javascript 语言的特性，只需要有 node，即可启动 Next.js 服务，也就是说：</p>
<blockquote>
<p>Next.js 的编译产物与架构无关</p>
</blockquote>
<p><img src="/blog-img/nextjs-docker/image%201.png" alt="image.png"></p>
<p>了解这一点后，Next.js 的交叉编译无非就是将编译产物分发到不同架构镜像中的过程。</p>
<h3>统一构建</h3>
<p>最终，我们将构建的过程在宿主机架构的镜像中进行，并将其分发到不同架构的运行镜像中，而改造过程也十分简单，只需将我们的 base 容器与架构关联起来。</p>
<p>从</p>
<pre><code class="language-docker">FROM oven/bun:1 AS base
</code></pre>
<p>变成</p>
<pre><code class="language-docker">FROM --platform=$BUILDPLATFORM oven/bun:1 AS base
</code></pre>
<p>编译后，一样地，从 builder 中分发到不同架构的runner中：</p>
<pre><code class="language-docker">FROM oven/bun:1-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
</code></pre>
<p>需要注意⚠️的是：</p>
<pre><code class="language-docker">FROM oven/bun:1-alpine AS runner
</code></pre>
<p>默认会使用--target=<strong>$TARGETPLATFORM</strong> 的镜像，也就是目标架构的镜像，因此我们可以省略这一参数。</p>
<p>让我们开始构建吧！</p>
<pre><code class="language-docker">FROM --platform=$BUILDPLATFORM oven/bun:1 AS base

# Stage 1: Install dependencies
FROM base AS deps
WORKDIR /app
COPY package.json bun.lockb ./
RUN bun install --frozen-lockfile

# Stage 2: Build the application
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN bun run build

# Stage 3: Production image
FROM oven/bun:1-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static

EXPOSE 3000
CMD ["bun", "run", "server.js"]
</code></pre>
<p>最终，通过一系列的优化，构建多架构镜像的时间:</p>
<p><img src="/blog-img/nextjs-docker/CleanShot_2024-10-25_at_10.04.442x.png" alt="CleanShot 2024-10-25 at 10.04.44@2x.png"></p>
<p>15m 25s → 2m 14s</p>
<p><img src="/blog-img/nextjs-docker/CleanShot_2024-10-25_at_10.03.322x.png" alt="CleanShot 2024-10-25 at 10.03.32@2x.png"></p>
<p>完整的 GitHub Actions 文件、Dockerfile 与构建记录，可在我的开源项目中查看：</p>
<p><a href="https://github.com/hamster1963/nezha-dash">https://github.com/hamster1963/nezha-dash</a></p>]]></description><link>https://buycoffee.top/blog/tech/nextjs-docker</link><guid isPermaLink="true">https://buycoffee.top/blog/tech/nextjs-docker</guid><dc:creator><![CDATA[Hamster1963]]></dc:creator><pubDate>Fri, 25 Oct 2024 00:00:00 GMT</pubDate><enclosure url="https://buycoffee.top/blog-img/nextjs-docker/cover.jpeg" length="0" type="image/jpeg"/></item><item><title><![CDATA[NextMe 一个简洁的博客框架]]></title><description><![CDATA[<h3>前言</h3>
<p><strong>NextMe 是一个使用 Next.js 和 Tailwind CSS 构建的个人网站。</strong></p>
<p><a href="https://github.com/hamster1963/nextme">NextMe 仓库地址</a></p>
<p>目前有太多可选的博客系统，各式令人眼花缭乱的CMS系统与艰难的部署过程，让许多开发者将时间折腾在部署与维护而不是写作上。</p>
<p>同时，随着各式设计风格的涌现，许多站点将炫酷与时髦摆在第一位，而将最重要的观感与用户体验抛之脑后。</p>
<p>如果你正在寻找一个简约而不失设计感的个人站点，相信 NextMe 能够让你在专注写作的同时，也可以完全自定义你的站点，并且保持着极高的性能与良好的用户体验。</p>
<h3>如何部署？</h3>
<p>你可以轻松地将站点部署在 Vercel 与 Cloudflare 上以获得最佳的 CDN 体验，或者你也可以自行构建，将生成的静态文件部署到任意的 VPS 甚至容器中。</p>
<h4>Vercel</h4>
<p>在仓库的 Readme 文件中，你可以找到 Vercel 的一键部署按钮。</p>
<p>Vercel 会为你创建一个 NextMe 模版仓库，而往后在你推送代码至仓库时，Vercel 会自动为你构建最新的版本。</p>
<h4>Cloudflare</h4>
<p>将站点部署到 Cloudflare 也十分简单，将仓库 Fork 后，在 Cloudflare Page 中选择仓库进行创建。</p>
<p>需要注意的是</p>
<ul>
<li>框架预设选择 Next.js(Static HTML Export)</li>
<li>环境变量必须填入</li>
</ul>
<p>BUN_VERSION = 1.1.29</p>
<p><img src="/blog-img/nextme/CleanShot_2024-10-15_at_16.26.402x.png" alt="CleanShot 2024-10-15 at 16.26.40@2x.png"></p>
<h3>如何自定义？</h3>
<p>由于不涉及到任何的外部依赖，因此全部的自定义都可以通过代码实现</p>
<ul>
<li>主页 - /app/page.tsx</li>
<li>作品页面 - /app/work/page.tsx &#x26; /app/work/(project)</li>
<li>博客列表页 - /blog/page.tsx</li>
</ul>
<h3>编写博客</h3>
<p>全部的博客内容与静态文件都通过本地存储的方式进行管理。</p>
<p>其中，文章中的本地图片将会在构建时自动进行优化，并生成占位符以优化图片加载体验。</p>
<p>博客内容编写在 mdx 格式的文件中，存储在 <strong>content</strong> 文件夹下。</p>
<p>关于 MDX，可查看：</p>
<p><a href="https://buycoffee.top/blog/tech/blog-2024#%E6%9C%AC%E5%9C%B0-mdx-%E6%96%87%E4%BB%B6%E7%AE%A1%E7%90%86">https://buycoffee.top/blog/tech/blog-2024#本地-mdx-文件管理</a></p>
<p>静态文件存储在 <strong>public</strong> 文件夹下，在 mdx 文件中直接使用 <strong>public</strong> 内的路径即可。</p>
<p>💡 例如图片在项目中的路径为</p>
<pre><code>/public/blog-img/first-load-js/Untitled.png
</code></pre>
<p>在 mdx 中引用图片的路径则为</p>
<pre><code>![Untitled](/blog-img/first-load-js/Untitled%201.png)
</code></pre>
<h3>博客元数据</h3>
<p>在博客顶部，定义着博客的一些基础信息</p>
<pre><code class="language-json">---
title: 'First Load JS Optimization'
publishedAt: '2024-04-25'
summary: 'First Load JS'
image: '/blog-img/first-load-js/cover.webp'
rssImage: '/blog-img/first-load-js/cover.jpeg'
category: 'Tech'
ai: '本文介绍了如何通过动态导入优化前端页面性能。'
---
</code></pre>
<p>其中，<strong>category</strong> 定义着博客是否会出现在默认的博客列表以及 RSS 信息中。</p>
<ul>
<li>Tech - 出现在默认博客列表与 RSS 信息中</li>
<li>Daily - 出现在选项页面，不会出现在 RSS 信息中</li>
</ul>
<p><img src="/blog-img/nextme/CleanShot_2024-10-15_at_16.41.082x.png" alt="CleanShot 2024-10-15 at 16.41.08@2x.png"></p>
<p>此外，就不对这些定义类型进行太多的详细介绍。</p>
<h3>最后</h3>
<p>欢迎大家去尝试使用博客框架，如果希望大家可以看到您的博客，可以在下方评论区留言，谢谢🙏。</p>]]></description><link>https://buycoffee.top/blog/tech/nextme</link><guid isPermaLink="true">https://buycoffee.top/blog/tech/nextme</guid><dc:creator><![CDATA[Hamster1963]]></dc:creator><pubDate>Tue, 15 Oct 2024 00:00:00 GMT</pubDate><enclosure url="https://buycoffee.top/blog-img/nextme/cover.jpeg" length="0" type="image/jpeg"/></item><item><title><![CDATA[nezha-dash Cloudflare部署教程]]></title><description><![CDATA[<aside>
❗
<p>只适用于面板可通过域名访问的哪吒面板。</p>
<p>IP + 端口访问面板的方式不适用于 Cloudflare 部署。</p>
</aside>
<h2>项目介绍</h2>
<p><a href="https://buycoffee.top/blog/tech/nezha">nezha-dash 简易部署教程</a></p>
<h2>项目部署</h2>
<h3>获取 nezha 面板地址URL</h3>
<p><img src="/blog-img/nezha-cloudflare/CleanShot_2024-09-24_at_14.49.182x.png" alt="CleanShot 2024-09-24 at 14.49.18@2x.png"></p>
<p>获取完整的面板 URL 根路径，包含 http:// 或 https://，如通过端口访问也需记录。</p>
<blockquote>
<p>例如：<a href="https://nezha-dashboard.hku.tech:8843">https://nezha-dashboard.hku.tech:8843</a></p>
</blockquote>
<h3>获取 nezha API Token</h3>
<p><strong>创建 Token 位置</strong>：管理后台 → API Token</p>
<p><img src="/blog-img/nezha-cloudflare/Untitled.png" alt="Untitled"></p>
<p><img src="/blog-img/nezha-cloudflare/Untitled%201.png" alt="Untitled"></p>
<p><img src="/blog-img/nezha-cloudflare/Untitled%202.png" alt="Untitled"></p>
<h3>Fork 至自己仓库, 取消勾选最下面的选项。</h3>
<p><img src="/blog-img/nezha-cloudflare/Untitled%203.png" alt="Untitled"></p>
<p><img src="/blog-img/nezha-cloudflare/fork.png" alt="fork"></p>
<h3>创建 Cloudflare Pages</h3>
<p><img src="/blog-img/nezha-cloudflare/CleanShot_2024-09-24_at_14.53.182x.png" alt="CleanShot 2024-09-24 at 14.53.18@2x.png"></p>
<p><img src="/blog-img/nezha-cloudflare/CleanShot_2024-09-24_at_14.54.152x.png" alt="CleanShot 2024-09-24 at 14.54.15@2x.png"></p>
<h3>关联仓库</h3>
<p>选择刚才 fork 至自己仓库内的存储库</p>
<p><img src="/blog-img/nezha-cloudflare/CleanShot_2024-09-24_at_14.54.472x.png" alt="CleanShot 2024-09-24 at 14.54.47@2x.png"></p>
<h3>配置项目基础信息（十分重要！）</h3>
<p>在<strong>生产分支</strong>中，选择 <strong>cloudflare</strong> 分支</p>
<p>在<strong>框架预设</strong>中，选择 <strong>Next.js</strong> , 构建命令与目录无需更改</p>
<p><img src="/blog-img/nezha-cloudflare/CleanShot_2024-09-24_at_14.56.182x.png" alt="CleanShot 2024-09-24 at 14.56.18@2x.png"></p>
<h3>配置必要环境变量</h3>
<p>在配置面板信息前，首先需要配置构建工具版本。</p>
<p><img src="/blog-img/nezha-cloudflare/CleanShot_2024-09-24_at_14.59.582x.png" alt="CleanShot 2024-09-24 at 14.59.58@2x.png"></p>
<p><strong>变量名称：BUN_VERSION</strong></p>
<p><strong>值：1.1.29</strong></p>
<p><strong>变量名称：NODE_VERSION</strong></p>
<p><strong>值：22.9.0</strong></p>
<h3>配置面板信息环境变量</h3>
<p>环境变量解释可参考项目仓库 Readme</p>
<p><a href="https://github.com/hamster1963/nezha-dash/blob/main/README.md#%E7%8E%AF%E5%A2%83%E5%8F%98%E9%87%8F">nezha-dash/README.md at main · hamster1963/nezha-dash</a></p>
<p>必要环境变量填写完毕后如图：</p>
<p><img src="/blog-img/nezha-cloudflare/CleanShot_2024-09-24_at_15.02.572x.png" alt="CleanShot 2024-09-24 at 15.02.57@2x.png"></p>
<h3>保存并部署</h3>
<p>点击<strong>保存并部署</strong>按钮，等待部署完成，部署完成后进行后续设置。</p>
<h3>配置 node 兼容项</h3>
<p><img src="/blog-img/nezha-cloudflare/CleanShot_2024-09-24_at_15.06.372x.png" alt="CleanShot 2024-09-24 at 15.06.37@2x.png"></p>
<p>部署完成后，会提示未开启 nodejs 兼容选项。</p>
<p>在设置-运行时-兼容性标志中，填入 nodejs_compat 后保存。</p>
<p><img src="/blog-img/nezha-cloudflare/CleanShot_2024-09-24_at_15.08.342x.png" alt="CleanShot 2024-09-24 at 15.08.34@2x.png"></p>
<h3>禁用其他分支部署</h3>
<p>在 <strong>设置-运行时-分支控制</strong> 中，将预览分支选项勾选为 <strong>无，<strong>点击</strong>部署</strong>。</p>
<p><img src="/blog-img/nezha-cloudflare/CleanShot_2024-09-24_at_15.09.452x.png" alt="CleanShot 2024-09-24 at 15.09.45@2x.png"></p>
<h3>重新部署项目即可完成部署</h3>
<p>在<strong>部署</strong>标签页，选中<strong>最新一条部署</strong>，在选项中点击<strong>重试部署</strong>。</p>
<p>部署完成后即可访问仪表盘页面与自定义域的绑定。</p>
<p><img src="/blog-img/nezha-cloudflare/CleanShot_2024-09-24_at_15.11.262x.png" alt="CleanShot 2024-09-24 at 15.11.26@2x.png"></p>
<p>谢谢大家的支持🙏</p>]]></description><link>https://buycoffee.top/blog/tech/nezha-cloudflare</link><guid isPermaLink="true">https://buycoffee.top/blog/tech/nezha-cloudflare</guid><dc:creator><![CDATA[Hamster1963]]></dc:creator><pubDate>Tue, 24 Sep 2024 00:00:00 GMT</pubDate><enclosure url="https://buycoffee.top/blog-img/nezha-cloudflare/cover.jpeg" length="0" type="image/jpeg"/></item><item><title><![CDATA[开源项目的 GitHub 1K Star]]></title><description><![CDATA[<p>许久没敲记录日常的文字，忙忙碌碌的几个月过去。</p>
<p>每次敲下几个字后，又因为觉得矫情而又放弃。</p>
<p>但最近发生的事情确实值得记录一下，以后看回来，也是一段十分有趣的经历吧。</p>
<h2>nezha-dash</h2>
<p>最近的个人时间基本上都奉献给了 nezha-dash 这个开源项目。</p>
<p><strong>项目地址：</strong><a href="https://github.com/hamster1963/nezha-dash">https://github.com/hamster1963/nezha-dash</a></p>
<p><img src="/blog-img/nezha-dash-1024/CleanShot_2024-12-09_at_15.57.512x.png" alt="CleanShot 2024-12-09 at 15.57.51@2x.png"></p>
<p>在各种 issues 与 pr 中穿梭，有时候也在群组中与天南地北的社区朋友交流各种 feature 与 bug，热闹的时候几十人讨论一下小功能，大家都很友善，无论是否是程序员都可以说出自己的看法与意见，实属是难得的融洽。</p>
<p>而最令我开心的，则是不久前项目收获了第 1000 个 star。</p>
<p><img src="/blog-img/nezha-dash-1024/CleanShot202024-11-2820at2015.49.402x.png" alt="CleanShot%202024-11-28%20at%2015.49.40@2x.png"></p>
<p>一路上项目收到了很多朋友的鼓励， 也是在这个过程中收到了人生中的第一笔赞赏，有些在 telegram 交流过，有些是匿名的朋友。</p>
<p><a href="https://buycoffee.top/coffee">https://buycoffee.top/coffee</a></p>
<p>在这里对每一位赞赏过、提出建议、帮助这个项目变得更好的朋友表达个人浅薄的感谢，无以为报，努力未来创造更好的开源项目。</p>
<h3>起源</h3>
<p>这个项目并不是突发奇想，也不是通过精心筹划的，而只是我自用项目的一部分。</p>
<p><a href="https://home.buycoffee.top/">HomeDash</a></p>
<p><img src="/blog-img/nezha-dash-1024/CleanShot_2024-12-09_at_16.17.282x.png" alt="CleanShot 2024-12-09 at 16.17.28@2x.png"></p>
<p>7 月的某天，社区的朋友看到了这个监控页面，询问是否有部署的方式，也就是从这个时候起，萌生了创造这个项目的念头。</p>
<p><img src="/blog-img/nezha-dash-1024/CleanShot_2024-12-09_at_16.22.232x.png" alt="CleanShot 2024-12-09 at 16.22.23@2x.png"></p>
<p>说干就干，2 天后的 7 月 26 号，推出了第一个版本。</p>
<p>当时并没有如今的详情页，地图，分组这些功能，仅仅是将 homedash 项目中的组件搬到一个新的项目，采用 next.js 的后端去通过 API 获取哪吒监控的服务器数据用以展示，仅此而已。</p>
<p><img src="/blog-img/nezha-dash-1024/shotOne.png" alt="alt:v0.0.1 的 nezha-dash"></p>
<p>项目上线后，本没有对这个项目寄予太多的希望，一方面是这个项目的架构设计是为了可以方便地在 Vercel 上部署，另一方面是官方的前端也足够好，感兴趣的朋友应该不会太多。</p>
<p>项目在发布的前几天收获了不少的 star，但在 100 star 之后就较为沉寂了，在这期间，有一些功能的请求我暂时的搁置，一方面是工作上确实很忙，另一方面是不太确定这些需求是否是大多数用户所需要的。</p>
<p>转折点来自于一位博主的推荐。</p>
<h3>意外的博主推荐</h3>
<p>9 月的某天，意外地在推特的首页发现了有博主竟然在宣传 nezha-dash这个项目。</p>
<p><img src="/blog-img/nezha-dash-1024/CleanShot_2024-12-09_at_16.55.222x.png" alt="CleanShot 2024-12-09 at 16.55.22@2x.png"></p>
<p>这次宣传给项目带来的流量给我带来了不小的震撼。</p>
<p>项目开始获得越来越多的关注，也开始了新功能的开发。</p>
<p>在完成网络延迟图表与兼容多环境部署后，项目来到了 500 star。</p>
<p><img src="/blog-img/nezha-dash-1024/CleanShot_2024-12-09_at_17.08.452x.png" alt="CleanShot 2024-12-09 at 17.08.45@2x.png"></p>
<h3>社区推荐</h3>
<p>项目被越来越多的朋友关注，其中很大一部分是来自于各个社区的推荐。</p>
<p><a href="https://www.nodeseek.com/">NodeSeek</a></p>
<p><a href="https://linux.do/">LINUX DO</a></p>
<p><a href="https://hostloc.com/">全球主机交流论坛 - Powered by Discuz!</a></p>
<p>十分感谢社区朋友的大力支持，甚至有博主在 YouTube 上推出了详细的部署教程，感谢大家🙏。</p>
<p><img src="/blog-img/nezha-dash-1024/CleanShot_2024-12-09_at_17.14.242x.png" alt="CleanShot 2024-12-09 at 17.14.24@2x.png"></p>
<h3>繁忙的日子</h3>
<p>在功能越来越多后，各式的 issue 与改善建议让我几乎投入了全部的个人时间在这个项目上。</p>
<p><img src="/blog-img/nezha-dash-1024/shot-3.png" alt="shot-3.png"></p>
<p>常常是，手机上一收到 issue 通知，5 分钟内完成回复，快的 1 个小时内完成，如果是功能请求也大概率会在 1 天内完成，这样的状态也被群嘲为摸鱼狂人（🤣</p>
<p>在构建这个项目的路上，尤其感谢为这个项目提供 pr 的 kuusei 与 FarEastTZ。</p>
<p><img src="/blog-img/nezha-dash-1024/CleanShot_2024-12-09_at_17.24.062x.png" alt="CleanShot 2024-12-09 at 17.24.06@2x.png"></p>
<p>kuusei 在项目早期解决了关键的类型问题与 IP 泄露的问题，FarEastTZ 用一个变量解决了国旗在不同设备的显示问题，感谢他们的贡献。</p>
<p><img src="/blog-img/nezha-dash-1024/CleanShot_2024-12-09_at_17.28.272x.png" alt="CleanShot 2024-12-09 at 17.28.27@2x.png"></p>
<p>在这段时间内，处理了快 100 个 issue，虽然累，但看到项目被越来越多人关注，看到项目在一步步达到心中期待的样子，咬咬牙也就坚持下去了。</p>
<h3>1K 时刻</h3>
<p>1K star 对于之前参与开源项目的我来说，像是一座难以跨域的高山。</p>
<p>我本职为一名 Go Web 后端开发，在前端领域更像是一名探索的新手，对于设计也只是来自于无数个小时的 UI 微调中获得的感受。</p>
<p>因此能在这个领域创建一个项目还能获取不错的反响，给了我莫大的信心与鼓励，但对于 1K star，还是停留在想象的阶段。</p>
<p>因此在项目接近 700 star 的时候，我开始尝试实现一个可能会让大家喜欢的功能，世界地图。</p>
<p><img src="/blog-img/nezha-dash-1024/2.webp" alt="2.webp"></p>
<p>世界地图对于我来说实在是太难了，过程中不断的询问 ai 来实现的过程让我心烦意乱，各种 geo 数据的处理，各种边界条件与算法，让我一度想要放弃这个功能。</p>
<p>好在最后还是艰难地实现了，发布到了社区。</p>
<p>地图功能一出就收到了许多的 issue ，总结起来就是：</p>
<blockquote>
<p>点状的地图，好看、简洁，但不好用。</p>
</blockquote>
<p>于是又吭哧吭哧地重构地图，变成了现在的这个样子。</p>
<p><img src="/blog-img/nezha-dash-1024/CleanShot_2024-12-09_at_17.43.332x.png" alt="CleanShot 2024-12-09 at 17.43.33@2x.png"></p>
<p>最终在 2024 年 11 月 28 号下午，nezha-dash 项目收获了第 1000 个 star。</p>
<p>心情混杂着激动，期待，与迷茫。</p>
<h2>nezha-dash-v1</h2>
<p>后面的项目发展大家应该也知道了，nezha-dash 作为 V1 的默认前端加入了哪吒监控的大家庭。</p>
<p>提到 V1，虽然界面看起来相同，但在代码实现上其实有许多不同。</p>
<p>nezha-dash 的项目框架为 next.js ，数据在 next.js 的服务端代理获取哪吒监控的数据并处理。</p>
<p>而 nezha-dash-V1 则是完全由 vite+react 来重写，直接通过 ws 与哪吒监控 api 来获取数据。</p>
<p>近期也终于将 nezha-dash 的功能逐步在 V1 版本中全部实现。</p>
<p><img src="/blog-img/nezha-dash-1024/CleanShot_2024-12-09_at_18.01.452x.png" alt="CleanShot 2024-12-09 at 18.01.45@2x.png"></p>
<p>小插曲是，本来管理后台也由我开发，但工作与开源项目的各项开发实在让我无法抽身，只能草草结束在设计与原型开发的阶段。</p>
<p><img src="/blog-img/nezha-dash-1024/2024-12-09_17.51.02.jpg" alt="alt: 当时的后台初步设计"></p>
<p>感谢 naiba 与 Uub 接起了后台开发的重担，使得 V1 可以完成全部的开发。</p>
<h2>后言</h2>
<p>从写下 nezha-dash 的第一行代码到现在，短短 4 个月的时间，让我收获到的不仅是这 1K star，更多的是感受到了开源项目开发者在其中不断交错的成就感，疲惫感，与创造后从内心感受到的由衷的喜悦。</p>
<p>感谢大家的一路陪伴，文笔有限，我会继续努力，与大家在代码中会面。</p>]]></description><link>https://buycoffee.top/blog/tech/nezha-dash-1024</link><guid isPermaLink="true">https://buycoffee.top/blog/tech/nezha-dash-1024</guid><dc:creator><![CDATA[Hamster1963]]></dc:creator><pubDate>Tue, 10 Dec 2024 00:00:00 GMT</pubDate><enclosure url="https://buycoffee.top/blog-img/nezha-dash-1024/cover.jpeg" length="0" type="image/jpeg"/></item><item><title><![CDATA[nezha-dash Docker部署教程]]></title><description><![CDATA[<h2>Docker部署项目</h2>
<p>如需简易Vercel部署，可查看文章：</p>
<p><a href="https://buycoffee.top/blog/tech/nezha">nezha-dash 简易部署教程</a></p>
<p>项目镜像已上传至 Docker Hub 。</p>
<p>可在仓库 docker 文件夹中找到 docker compose 配置文件与 .env.example 环境变量参考文件。</p>
<h2>获取 docker compose 配置文件</h2>
<p><a href="https://github.com/hamster1963/nezha-dash/tree/main/docker">https://github.com/hamster1963/nezha-dash/tree/main/docker</a></p>
<ul>
<li>docker-compose.yml 镜像地址为Docker Hub</li>
</ul>
<p>在服务器中创建名为 nezha-dash 的文件夹，将 docker-compose.yml 配置文件放入文件夹。</p>
<h2>配置 .env 环境变量文件</h2>
<p>在相同文件夹中创建一个.env文件</p>
<pre><code class="language-bash">touch .env
</code></pre>
<p>将配置内容复制到文件中。</p>
<p><a href="https://github.com/hamster1963/nezha-dash/blob/main/docker/.env.example">https://github.com/hamster1963/nezha-dash/blob/main/docker/.env.example</a></p>
<p>默认必须包含以下 2 个配置项。</p>
<pre><code class="language-jsx">NezhaBaseUrl=http://120.34.XX.XX:8008
NezhaAuth=your-nezha-token
</code></pre>
<h3>获取 nezha 面板地址URL</h3>
<p><img src="/blog-img/nezha-cloudflare/CleanShot_2024-09-24_at_14.49.182x.png" alt="CleanShot 2024-09-24 at 14.49.18@2x.png"></p>
<p>获取完整的面板 URL 根路径，包含 http:// 或 https://，如通过端口访问也需记录,作为 NezhaBaseUrl 填入 .env 文件</p>
<blockquote>
<p>例如：<a href="https://nezha-dashboard.hku.tech:8843">https://nezha-dashboard.hku.tech:8843</a></p>
</blockquote>
<h3>获取 nezha API Token</h3>
<p><strong>创建 Token 位置</strong>：管理后台 → API Token
Token 作为 NezhaAuth 填入 .env 文件</p>
<p><img src="/blog-img/nezha-cloudflare/Untitled.png" alt="Untitled"></p>
<p><img src="/blog-img/nezha-cloudflare/Untitled%201.png" alt="Untitled"></p>
<p><img src="/blog-img/nezha-cloudflare/Untitled%202.png" alt="Untitled"></p>
<p>环境变量的说明可参考：</p>
<p><a href="https://github.com/hamster1963/nezha-dash/blob/main/README.md">nezha-dash/README.md at main · hamster1963/nezha-dash</a></p>
<h2>检查文件</h2>
<p>环境变量填写完成后，检查文件夹中是否已包含 docker-compose.yml 与 .env 文件。</p>
<p><img src="/blog-img/nezha-docker/CleanShot_2024-09-22_at_22.32.542x.png" alt="CleanShot 2024-09-22 at 22.32.54@2x.png"></p>
<h2>部署服务</h2>
<h3>拉取镜像</h3>
<pre><code class="language-jsx">docker compose pull
</code></pre>
<p>或</p>
<pre><code class="language-jsx">docker-compose pull
</code></pre>
<h3>启动服务</h3>
<pre><code class="language-jsx">docker compose up -d
</code></pre>
<p>或</p>
<pre><code class="language-jsx">docker-compose up -d
</code></pre>
<h2>访问服务</h2>
<p>在浏览器中，访问服务器 <code>4123</code> 端口即可访问到仪表盘。</p>
<p><img src="/blog-img/nezha-docker/CleanShot_2024-09-22_at_22.38.022x.png" alt="CleanShot 2024-09-22 at 22.38.02@2x.png"></p>
<p>任何问题可在仓库中发起 issue 或在下方评论区发表，谢谢🙏。</p>]]></description><link>https://buycoffee.top/blog/tech/nezha-docker</link><guid isPermaLink="true">https://buycoffee.top/blog/tech/nezha-docker</guid><dc:creator><![CDATA[Hamster1963]]></dc:creator><pubDate>Sun, 22 Sep 2024 00:00:00 GMT</pubDate><enclosure url="https://buycoffee.top/blog-img/nezha-docker/cover.jpeg" length="0" type="image/jpeg"/></item><item><title><![CDATA[nezha-dash 更新教程]]></title><description><![CDATA[<p>首先，感谢大家对这个项目的支持与提出的建议，关于部署教程，可查看以下链接。</p>
<h3>项目地址</h3>
<p><a href="https://github.com/hamster1963/nezha-dash">nezha-dash</a></p>
<h3>部署教程</h3>
<p><a href="https://buycoffee.top/blog/tech/nezha">nezha-dash 简易部署教程</a></p>
<p><a href="https://buycoffee.top/blog/tech/nezha-docker">nezha-dash Docker部署教程</a></p>
<p><a href="https://buycoffee.top/blog/tech/nezha-cloudflare">nezha-dash Cloudflare部署教程</a></p>
<h2>如何更新？</h2>
<p>许多朋友在部署 nezha-dash 后，对如何更新不太了解，这篇教程就以上面三种部署方式，简要的介绍一下更新步骤。</p>
<h3>Vercel 部署更新</h3>
<p>如果是在 Vercel 部署的朋友，可以手动在自己 fork 后的仓库，在 main 分支中，点击</p>
<p><strong>Sync fork</strong> - <strong>Update branch</strong></p>
<p>进行代码更新。</p>
<p><img src="/blog-img/nezha-upgrade/CleanShot_2024-10-11_at_08.50.252x.png" alt="CleanShot 2024-10-11 at 08.50.25@2x.png"></p>
<p>更新后，Vercel会自动拉取仓库中的最新代码进行部署，可在Vercel仪表盘中查看构建状态。</p>
<p><img src="/blog-img/nezha-upgrade/CleanShot_2024-10-11_at_08.53.292x.png" alt="CleanShot 2024-10-11 at 08.53.29@2x.png"></p>
<h3>Cloudflare 部署更新</h3>
<p>在cloudflare 部署的更新步骤与上述一致，不同的是第一步需要手动切换到 cloudflare分支。</p>
<p><img src="/blog-img/nezha-upgrade/CleanShot_2024-10-11_at_08.55.092x.png" alt="CleanShot 2024-10-11 at 08.55.09@2x.png"></p>
<p>切换到 <strong>cloudflare</strong> 分支后，点击</p>
<p><strong>Sync fork</strong> - <strong>Update branch</strong></p>
<p>进行代码更新。</p>
<p><img src="/blog-img/nezha-upgrade/CleanShot_2024-10-11_at_08.50.252x.png" alt="CleanShot 2024-10-11 at 08.50.25@2x.png"></p>
<p>更新代码后，cloudflare 会自动拉取最新代码进行构建部署，可在 cloudflare 仪表盘中查看。</p>
<p><img src="/blog-img/nezha-upgrade/CleanShot_2024-10-11_at_09.01.582x.png" alt="CleanShot 2024-10-11 at 09.01.58@2x.png"></p>
<h3>Docker 部署更新</h3>
<p>Docker 方式部署的更新步骤为：</p>
<ol>
<li>拉取最新镜像</li>
<li>重启服务</li>
</ol>
<h4>拉取最新镜像</h4>
<p>在 docker-compose.yml 文件夹下，执行拉取最新镜像命令：</p>
<pre><code class="language-json">docker compose pull
</code></pre>
<p>或者</p>
<pre><code class="language-json">docker-compose pull
</code></pre>
<p><img src="/blog-img/nezha-upgrade/CleanShot_2024-10-11_at_09.06.392x.png" alt="CleanShot 2024-10-11 at 09.06.39@2x.png"></p>
<h4>重启服务</h4>
<p>拉取最新镜像后，输入启动服务命令即可完成更新。</p>
<pre><code class="language-json">docker compose up -d
</code></pre>
<p>或者</p>
<pre><code class="language-json">docker-compose up -d
</code></pre>
<p><img src="/blog-img/nezha-upgrade/CleanShot_2024-10-11_at_09.07.302x.png" alt="CleanShot 2024-10-11 at 09.07.30@2x.png"></p>]]></description><link>https://buycoffee.top/blog/tech/nezha-upgrade</link><guid isPermaLink="true">https://buycoffee.top/blog/tech/nezha-upgrade</guid><dc:creator><![CDATA[Hamster1963]]></dc:creator><pubDate>Fri, 11 Oct 2024 00:00:00 GMT</pubDate><enclosure url="https://buycoffee.top/blog-img/nezha-upgrade/cover.jpeg" length="0" type="image/jpeg"/></item><item><title><![CDATA[nezha-dash 简易部署教程]]></title><description><![CDATA[<h2>项目信息</h2>
<p><a href="https://github.com/hamster1963/nezha-dash">https://github.com/hamster1963/nezha-dash</a></p>
<p>NezhaDash 是一个基于 Next.js 和 哪吒监控 的仪表盘。</p>
<p><img src="/blog-img/nezha/Untitled.png" alt="Untitled"></p>
<h3>项目架构</h3>
<p>为了使得项目可以部署在 Serverless 环境中，因此采用 Next.js 的 Route Handlers 作为后端进行数据获取。</p>
<p><a href="https://nextjs.org/docs/app/building-your-application/routing/route-handlers">Routing: Route Handlers</a></p>
<p>页面中通过 SWR 来进行全局数据的获取，在 Handlers 中进行 nezha 面板数据的获取与处理。</p>
<p><img src="/blog-img/nezha/Frame_10.png" alt="Frame 10.png"></p>
<h2>项目部署</h2>
<h3>获取 nezha 面板地址URL</h3>
<p><img src="/blog-img/nezha/Untitled%201.png" alt="Untitled"></p>
<p>获取完整的面板 URL 根路径。</p>
<h3>获取 nezha API Token</h3>
<p><strong>创建 Token 位置</strong>：管理后台 → API Token</p>
<p><img src="/blog-img/nezha/Untitled%202.png" alt="Untitled"></p>
<p><img src="/blog-img/nezha/Untitled%203.png" alt="Untitled"></p>
<p><img src="/blog-img/nezha/Untitled%204.png" alt="Untitled"></p>
<h3>Fork 至自己仓库</h3>
<p><img src="/blog-img/nezha/Untitled%205.png" alt="Untitled"></p>
<h3>在 Vercel 中创建新项目，填入环境变量</h3>
<p><strong>NezhaBaseUrl</strong>: 面板URL</p>
<p><strong>NezhaAuth</strong>: 管理后台创建的 API Token</p>
<p><strong>NEXT_PUBLIC_NezhaFetchInterval</strong>: 获取数据间隔（毫秒）</p>
<p>其他环境变量可以参考 <a href="https://github.com/hamster1963/nezha-dash/blob/main/README.md#%E7%8E%AF%E5%A2%83%E5%8F%98%E9%87%8F">nezha-dash 环境变量</a></p>
<p><img src="/blog-img/nezha/Untitled%206.png" alt="Untitled"></p>
<h2>Deploy! 大功告成！</h2>]]></description><link>https://buycoffee.top/blog/tech/nezha</link><guid isPermaLink="true">https://buycoffee.top/blog/tech/nezha</guid><dc:creator><![CDATA[Hamster1963]]></dc:creator><pubDate>Sat, 27 Jul 2024 00:00:00 GMT</pubDate><enclosure url="https://buycoffee.top/blog-img/nezha/cover.jpeg" length="0" type="image/jpeg"/></item><item><title><![CDATA[Goframe 服务监控]]></title><description><![CDATA[<h2>前言</h2>
<p>无论是在微服务还是在单体架构中，服务监控告警都是重要的组成部分，而对于不同的服务，有不同的监测数据的获取与处理方式，但本质上都是去主动收集汇总服务内部的指标数据并加以处理与展示。</p>
<h2>项目介绍</h2>
<p>在 Go 的单体服务中，在服务监测这一部分常常会采用 <strong>Prometheus</strong> 加 <strong>Grafana</strong> 的方案。</p>
<ul>
<li><strong>Prometheus</strong> 用于记录由分布式系统产生的实时指标数据</li>
<li><strong>Grafana</strong> 数据可视化和监控平台，常用于与 Prometheus 等数据源集成</li>
</ul>
<p><img src="/blog-img/otel/Untitled.png" alt="alt: Grafana Public Dashboard"></p>
<h2>服务监控</h2>
<p>在这篇实做中，核心主要关注于在服务内部，通过代码进行监控数据导出的配置，并与 <strong>Prometheus</strong> 进行对接。</p>
<h3><strong>Prometheus 的数据拉取</strong></h3>
<p><strong>Prometheus</strong> 采用 Pull 的方式去获取数据，通过在配置文件中配置需要拉取的端点和间隔（静态配置），就可以使得 <strong>Prometheus</strong> 主动去拉取对应的数据。</p>
<p>除了静态配置外，<strong>Prometheus</strong> 也支持多种服务发现机制，可以自动发现动态变化的目标。这些机制包括 Kubernetes、Consul、Amazon EC2、Azure、Docker Swarm 等。</p>
<h3>OpenTelemetry 框架</h3>
<p>在 Goframe 框架中，采用 OpenTelemetry 开源可观测性框架去实现监测数据的导出。</p>
<p><img src="/blog-img/otel/Untitled%201.png" alt="alt:Goframe OpenTelemetry 内部架构"></p>
<p>由于 <strong>Prometheus</strong> 是采用 Pull 的方式去拉取数据，因此我们需要在项目中配置一个 <code>/metrics</code> 的路由，专门用于暴露监测数据。</p>
<p>在代码的实现中，Goframe 框架内部已经对 Meter 部分做了封装处理，因此我们只需要配置 Metric Exporter 与绑定 Provider 即可将内部的指标数据导出。</p>
<p><a href="https://goframe.org/pages/viewpage.action?pageId=148537371">监控告警-基本使用 - GoFrame (ZH)-Latest - GoFrame官网 - 类似PHP-Laravel, Java-SpringBoot的Go企业级开发框架</a></p>
<h2><strong>Prometheus 数据端点配置</strong></h2>
<p>在endpoint 的配置上，只需绑定 Provider 与暴露 <code>/metrics</code> 路由即可。</p>
<h3>gmetric.Provider 绑定</h3>
<p>将 Provider 作为全局的中间件的形式进行处理，主要分为两步：</p>
<ol>
<li>创建 <strong>Prometheus Exporter</strong> 作为 <strong>Exporter</strong></li>
<li>将 <strong>Exporter</strong> 绑定到 <code>otelmetric</code> 的 <strong>Provider</strong></li>
</ol>
<p><img src="/blog-img/otel/ray-so-export.png" alt="ray-so-export.png"></p>
<p>在这一步中便创建了用于数据导出的 <strong>Provider。</strong></p>
<h3>创建监控 /metrics 路由</h3>
<p>在上一步创建好 <strong>Provider</strong> 后，我们需要实例化 <strong>Provider</strong> 并将其设置为全局，以便使得后续创建的 <code>/metrics</code> 路由得以使用。</p>
<p>而后我们将 <code>/metrics</code> 路由绑定在 <code>promhttp.Handler()</code> 中，实现 <strong>Prometheus</strong> 格式数据的导出。</p>
<p><img src="/blog-img/otel/ray-so-export-2.png" alt="ray-so-export-2.png"></p>
<p>绑定好路由后，便可以通过 <code>/metrics</code> 路由去获取监控指标数据。</p>
<h3><strong>Prometheus 配置</strong></h3>
<p>只需在配置文件的 <strong>scrape_configs</strong> 中添加静态配置即可：</p>
<p><img src="/blog-img/otel/Frame_12.png" alt="Frame 12.png"></p>
<p>配置好后启动 <strong>Prometheus</strong> ，在控制台中看到端点正常则配置成功。</p>
<p><img src="/blog-img/otel/Untitled%202.png" alt="Untitled"></p>
<h2>后言</h2>
<p>配置好 <strong>Prometheus</strong> 后，即可将其中收集处理的数据使用各种仪表盘工具显示出来，在 Grafana 中即可方便的将数据导入并展示，希望这篇博客对你在 Goframe 中配置服务监控有所帮助。</p>]]></description><link>https://buycoffee.top/blog/tech/otel</link><guid isPermaLink="true">https://buycoffee.top/blog/tech/otel</guid><dc:creator><![CDATA[Hamster1963]]></dc:creator><pubDate>Mon, 05 Aug 2024 00:00:00 GMT</pubDate><enclosure url="https://buycoffee.top/blog-img/otel/cover.jpeg" length="0" type="image/jpeg"/></item><item><title><![CDATA[使用自建 PoC 进行 API 代理]]></title><description><![CDATA[<p>国内未备案的服务器在使用上一直很棘手，不是在域名备案上，就是在暴露自建服务的繁琐上。</p>
<p>在没有域名备案的情况下，可以通过 IP 加端口的方式来暴露服务，或使用第三方的代理或隧道来转发服务。</p>
<h3>1. IP + 端口</h3>
<p>在这种方式下，调用方通过 IP:Port 的方式直接请求源站服务。</p>
<p><img src="/blog-img/poc/Untitled.png" alt="Untitled"></p>
<p>对于 IP 加端口的方式，除去安全性的问题，对于 HTTPS 的页面来说，还需要为 IP 配置可用的 SSL 证书，而目前市面上的 IP 证书申请起来比较费时费力。</p>
<p>目前免费的 IP 证书中，仅有 ZeroSSL 可签发的三个免费证书，而后续的续费都需要不低的费用。</p>
<p><img src="/blog-img/poc/Untitled%201.png" alt="alt:ZeroSSL 最基础的套餐费用"></p>
<p>同时对于 IP 证书来说，在可用性上也会略打折扣，可能会被某些浏览器或插件判定为不可信的站点，影响到服务的可用性。</p>
<p>因此不使用域名，直接通过 IP:Port 的方式进行服务暴露是不太实用的。</p>
<h3>2. 直接域名绑定</h3>
<p>在没有备案的情况下，无论是 HTTP 还是 HTTPS 下，通过域名访问服务器都会被云服务商检测并拦截。</p>
<p><img src="/blog-img/poc/Untitled%202.png" alt="Untitled"></p>
<p>因此直接将域名解析到国内云服务器的方式，在未备案的服务器上不可用。</p>
<p>而对于反向代理，目前云服务商也加大了检测力度，因此简单的 Nginx 反向代理也会被拦截。</p>
<h3>3. 第三方代理</h3>
<p>因此如果在未备案的情况下，想通过域名的方式进行接入，就需要一个“中转”。</p>
<p>中转的方式很多,比如:</p>
<ol>
<li><a href="https://vercel.com/guides/vercel-reverse-proxy-rewrites-external">Vercel Rewrites</a></li>
<li><a href="https://nextjs.org/docs/app/building-your-application/routing/route-handlers">Next.js Route Handlers</a></li>
</ol>
<p><img src="/blog-img/poc/Frame_1.png" alt="alt:利用 Vercel 和 Next.js 来进行请求转发"></p>
<ol start="3">
<li><a href="https://www.cloudflare.com/zh-cn/products/tunnel/">Cloudflare Tunnel</a></li>
</ol>
<p>对于这些服务，原理都与反向代理大致相同，先向数据包发往一个代理节点，代理节点与源服务器交换数据后进行返回。</p>
<p><img src="/blog-img/poc/Frame_4.png" alt="Frame 4.png"></p>
<p>但这些服务在国内都存在一定的问题。</p>
<p>对于 <strong>Vercel</strong>，最大的缺点在于费用，无论是 Vercel Rewrites 还是 Next.js 的 Route Handlers，使用的次数都会进行计费，虽然 Hobby 计划中包含着一定的使用量，但是长远来说，一旦使用超过限制，只能通过付费或者升级计划的方式来恢复服务。</p>
<p>而最近 Vercel 不断改动的计费规则，也会使得这个方案的可行性日渐降低。</p>
<p><img src="/blog-img/poc/Untitled%203.png" alt="alt:Vercel 仪表盘中的计费用量面板"></p>
<p>而对于 Tunnel，<strong>赛博佛祖</strong> <strong>Cloudflare</strong> 一如既往地让 Tunnel 可以免费使用。</p>
<p>但是，虽然在文档中描述 Tunnel 也采用了 Cloudflare 的边缘网络加速技术，但目前从实际测试与论坛调查来看，大陆方向几乎全部的请求都由美西的节点进行代理，这使得<strong>延迟</strong>成为了很大的问题。</p>
<p>同时 cloudflare 的连通性在大陆方向也日渐降低，加上代理节点集中在美西，转发效率实在不太高。</p>
<h2>自建 PoC</h2>
<p>在经过多次尝试降低 tunnel 的延迟和其他的中转方案后，突然想到，可以使用家宽的方式来进行服务的转发。</p>
<p><img src="/blog-img/poc/Frame_5.png" alt="Frame 5.png"></p>
<p>目前博客首页的播放组件背后就采用了这套自建的转发方案，实测就算在延迟 Double 的情况下，仍有不错的延迟表现。</p>
<p>（主要原因是国内延迟本来就比较低）</p>
<p><img src="/blog-img/poc/Untitled%204.png" alt="Untitled"></p>
<p>在构建这套简易的服务时，我希望将短链系统和代理结合在一起，服务通过短链找到原始请求 API ，并进行代理访问，返回最终的请求结果。</p>
<p>在架构上可以看到，最核心的服务需要部署在一台需要 <strong>有公网 IP 且无需备案</strong> 的机器上。</p>
<p>接下来就从</p>
<ol>
<li>短链系统</li>
<li>代理请求</li>
<li>PoC 前端界面</li>
</ol>
<p>简单从前后端介绍一下项目的组成。</p>
<h3>短链系统</h3>
<p>短链系统在大型系统中属于是基础服务一般的存在，因此在互联网上其实已经有许多关于如何构建短链系统的最佳实践。</p>
<p><a href="https://learning-guide.gitbook.io/system-design-interview/chapter-8-design-a-url-shortener">Design a URL Shortener</a></p>
<p>在常见的短链系统中，客户端通过一个短链访问服务器，而服务器返还一个重定向的原始链接。</p>
<blockquote>
<p>URL重定向：给定一个短的URL => 重定向到原来的URL</p>
</blockquote>
<p>而其中的短链生成我们通常通过一个哈希函数，将长链接映射到一串短字符串中。</p>
<p>关于一致性 hash 设计，也有许多的最佳实践。</p>
<p><a href="https://learning-guide.gitbook.io/system-design-interview/chapter-5-design-consistent-hashing">Design Consistent Hashing</a></p>
<p>而在本系统中，我们使用一个简单的随机字符串函数进行短链 hash 的生成。</p>
<p><img src="/blog-img/poc/ray-so-export.png" alt="ray-so-export.png"></p>
<p>这将会生成一个长度为 8 位的字符串作为短链中的 Hash。</p>
<p>完整的短链结构如下：</p>
<p><img src="/blog-img/poc/Frame_6.png" alt="Frame 6.png"></p>
<p>与常见的短链系统相同，从短链获取真实链接的延迟取决于存储的方式。</p>
<p>在 PoC 中，最终的数据存储在 MySQL 中，而从数据库获取键值对的结果往往也需要毫秒级别的时间，因此采用了内存缓存的方式将短链键值对存储。</p>
<p>在系统启动/短链操作这两个节点对缓存进行操作，在系统启动时，获取全部已启用短链存入缓存中，在对短链有增删的操作时也对缓存做对应的修改。</p>
<p>其中为了极致的速度，没有采用外部的例如 Redis 的缓存中间件，而是直接采用 <code>gcache</code> 作为缓存中间件，同时为了避免内存占用过大的问题，启动定时监测服务来确保内存占用在合理区间内。</p>
<p><img src="/blog-img/poc/Untitled%205.png" alt="Untitled"></p>
<p>同时，针对短链在缓存中未命中的问题，采用<code>gcache.GetOrSetFuncLock</code>来确保短链可用性的最大化。</p>
<p>完整的短链请求架构图如下：</p>
<p><img src="/blog-img/poc/Frame_7.png" alt="Frame 7.png"></p>
<h3>代理请求</h3>
<p>与常见的短链系统不同，PoC 通过请求中的短链获取真实链接后，需要进行代理访问后再将结果返回。</p>
<p>避免被云服务商检测为使用了反向代理，因此需要直接使用 httpClient 来进行请求的访问。</p>
<p>核心代码如下：</p>
<p><img src="/blog-img/poc/Untitled.jpeg" alt="Untitled"></p>
<p>可见我们通过重写请求头来进行一些例如认证参数的传递，通过组装参数来进行其他方式的请求。</p>
<p>同时，为了更好监测每个短链的访问数据与情况，通过管道与 defer 结合的方式将请求日志传入到日志消费者进行后续的入库与分析处理。</p>
<p><img src="/blog-img/poc/Frame_9.png" alt="Frame 9.png"></p>
<p>在完成请求后，通过捕获代理请求将返回数据返回到客户端中，便完成了整个的短链请求流程。</p>
<h3>PoC 前端页面</h3>
<p>前端页面主要参考了 clerk 与 medusa 的 UI 风格，以功能性为核心进行构建。</p>
<p><a href="https://clerk.com/">Clerk</a></p>
<p><img src="/blog-img/poc/Untitled%206.png" alt="Untitled"></p>
<p><a href="https://docs.medusajs.com/ui">Medusa UI</a></p>
<p><img src="/blog-img/poc/Untitled%207.png" alt="Untitled"></p>
<p>整体 PoC 站点如下：</p>
<p><img src="/blog-img/poc/Untitled%208.png" alt="Untitled"></p>
<p>站点使用 Next.js 进行构建，在权限控制方面，直接采用 session 来进行用户的认证与信息获取。</p>
<p>其中在数据获取部分，采用 SWR 配合 <code>refreshInterval</code> 来保持页面数据的不断更新。</p>
<p><img src="/blog-img/poc/ray-so-export_(2).png" alt="ray-so-export (2).png"></p>
<h2>后言</h2>
<p>构建这套“简陋单一”的系统的过程十分的开心，对于各种反向代理和网络协议的骚操作又有了许多的了解，同时在构建的过程中，参考了目前许多的后端与前端项目，十分感谢众多开源项目带来的灵感。</p>
<p>最后向 Next.js 的文档编写者致谢，感谢精美的配图带来的良好的阅读体验，因此也在此篇文章中尝试使用 Figma 照猫画虎地画了一些示意图，如有画的不清晰的地方，还望多见谅。</p>]]></description><link>https://buycoffee.top/blog/tech/poc</link><guid isPermaLink="true">https://buycoffee.top/blog/tech/poc</guid><dc:creator><![CDATA[Hamster1963]]></dc:creator><pubDate>Wed, 24 Jul 2024 00:00:00 GMT</pubDate><enclosure url="https://buycoffee.top/blog-img/poc/cover.jpeg" length="0" type="image/jpeg"/></item><item><title><![CDATA[家庭网络节点搭建]]></title><description><![CDATA[<h1>实现原因</h1>
<p>身边的几个朋友有着查查谷歌学术，逛逛ig的需求，但是都对科学上网了解不多且对软件的认知还停在vpn阶段。</p>
<p>因此计划利用家用服务器进行节点的搭建，对外只暴露为vmess节点，内部为多个机场与服务器组成的负载均衡节点，在保障科学上网稳定的同时也可以降低朋友们的学习成本，采用流行的软件进行连接即可。</p>
<h1>最终效果</h1>
<h2>外部节点效果</h2>
<p><img src="/blog-img/home-proxy/IMG_2162.jpg" alt="IMG_2162.jpg"></p>
<h2>x-ui面板管理节点界面</h2>
<p><img src="/blog-img/home-proxy/Untitled.png" alt="Untitled"></p>
<h2>v2rayA界面</h2>
<p><img src="/blog-img/home-proxy/Untitled%201.png" alt="Untitled"></p>
<h2>Uptime Kuma节点监控界面</h2>
<p><img src="/blog-img/home-proxy/Untitled%202.png" alt="Untitled"></p>
<h2>Bark推送示例</h2>
<p><img src="/blog-img/home-proxy/WechatIMG321.jpeg" alt="WechatIMG321.jpeg"></p>
<h1>架构设计</h1>
<p>架构为下图所示</p>
<p>在路由器针对单用户节点进行端口转发，在xui中设置出站流量转发至v2raya。</p>
<p><img src="/blog-img/home-proxy/Untitled%203.png" alt="Untitled"></p>
<h1>部署环境</h1>
<p>在nas上进行部署，配置如下图，配置不高但转发性能远大于家宽上传带宽，因此将就一下也可以。</p>
<p><img src="/blog-img/home-proxy/Untitled%204.png" alt="Untitled"></p>
<p>全部组件都在Docker上进行部署，便于后续的维护与升级。</p>
<p><img src="/blog-img/home-proxy/Untitled%205.png" alt="Untitled"></p>
<h1>v2raya搭建</h1>
<p><strong>项目地址</strong> <a href="https://github.com/v2rayA/v2rayA">https://github.com/v2rayA/v2rayA</a></p>
<p>本次项目的核心负载均衡部分由开源项目v2rayA进行控制，v2rayA 是一个支持全局透明代理的 V2Ray Linux 客户端，同时兼容SS、SSR、Trojan(trojan-go)、<a href="https://github.com/esrrhs/pingtunnel">PingTunnel</a>协议。</p>
<h2>采用Docker进行部署v2rayA</h2>
<p>采用Docker进行部署。在本项目中由于我们主要将v2rayA用以对外服务，因此在部署时不采用host模式，手动进行端口的开放。我们只需要开发管理后台的2017端口以及接收x-ui面板转发socks5流量的20172端口（可自行选择更换）。也可以开放更多端口供后续其他用途。</p>
<pre><code class="language-docker"># run v2raya
docker run -d \
  -p 2017:2017 \
  -p 20170-20172:20170-20172 \
  --restart=always \
  --name v2raya \
  -e V2RAYA_LOG_FILE=/tmp/v2raya.log \
  -v /etc/v2raya:/etc/v2raya \
  mzz2017/v2raya
</code></pre>
<h2>管理后台配置</h2>
<p>因为v2rayA的用途为节点的负载均衡，因此在设置中可以将分流关闭，对外只作为一个节点，并且将透明代理与系统代理关闭，不进行过多的流量处理。</p>
<p><img src="/blog-img/home-proxy/Untitled%206.png" alt="Untitled"></p>
<p>在设置中开启端口分享，在地址与端口中将socks5端口绑定到Docker部署时开放的端口。</p>
<p><img src="/blog-img/home-proxy/Untitled%207.png" alt="Untitled"></p>
<h2>节点的添加与负载均衡</h2>
<p>v2rayA可以添加服务器与订阅多种使用节点的方式，点击创建为新建单节点，点击导入则为订阅。</p>
<p>在添加完节点与订阅后，只需点击节点后的选择，即为加入负载均衡列表中。</p>
<p><img src="/blog-img/home-proxy/Untitled%208.png" alt="Untitled"></p>
<p>负载均衡的原理为针对每个节点进行延迟的测试，自动连接延迟最低的节点进行使用。在PROXY中可以配置延迟测试的站点，测试间隔时间。</p>
<p><img src="/blog-img/home-proxy/Untitled%209.png" alt="Untitled"></p>
<p>配置完成后，点击左上角运行按钮，等待显示正在运行蓝色按钮即配置完成。也可在左边栏中查看当前节点连接情况与延迟。</p>
<p><img src="/blog-img/home-proxy/Untitled%2010.png" alt="Untitled"></p>
<h1>x-ui搭建</h1>
<p>x-ui为一个支持多协议多用户的xray面板，在项目中只要用作对外开放节点的管理以及将出口流量转发至v2rayA。</p>
<h2>采用Docker进行部署x-ui</h2>
<p>在部署x-ui时，采用host模式进行部署，避免后续节点新增带来的端口映射问题。</p>
<pre><code class="language-docker">mkdir x-ui &#x26;&#x26; cd x-ui
docker run -itd --network=host \
    -v $PWD/db/:/etc/x-ui/ \
    -v $PWD/cert/:/root/cert/ \
    --name x-ui --restart=unless-stopped \
    enwaiax/x-ui:latest
</code></pre>
<p>部署完成后，访问ip:54321进行面板的访问，初始用户名与密码为admin。</p>
<p>登陆完成后，务必修改用户名与密码。</p>
<h2>x-ui流量转发配置</h2>
<p>此步将配置x-ui全部的出站流量转发至v2rayA中，达到采用负载均衡节点的目的。</p>
<p>在面板设置中的xray项目相关设置中进行设置。</p>
<pre><code class="language-json">{
  "api": {
    "services": ["HandlerService", "LoggerService", "StatsService"],
    "tag": "api"
  },
  "inbounds": [
    {
      "listen": "127.0.0.1",
      "port": 62789,
      "protocol": "dokodemo-door",
      "settings": {
        "address": "127.0.0.1"
      },
      "tag": "api"
    }
  ],
  "outbounds": [
    {
      "protocol": "socks",
      "settings": {
        "servers": [
          {
            "address": "192.168.31.93",
            "port": 20172,
            "users": []
          }
        ]
      }
    },
    {
      "protocol": "freedom",
      "tag": "direct",
      "settings": {}
    },
    {
      "protocol": "blackhole",
      "settings": {},
      "tag": "blocked"
    }
  ],
  "policy": {
    "system": {
      "statsInboundDownlink": true,
      "statsInboundUplink": true
    }
  },
  "routing": {
    "rules": [
      {
        "inboundTag": ["api"],
        "outboundTag": "api",
        "type": "field"
      },
      {
        "ip": ["geoip:private"],
        "outboundTag": "blocked",
        "type": "field"
      },
      {
        "type": "field",
        "domain": ["geosite:cn"],
        "outboundTag": "direct"
      },
      {
        "type": "field",
        "ip": ["geoip:cn"],
        "outboundTag": "direct"
      },
      {
        "outboundTag": "blocked",
        "protocol": ["bittorrent"],
        "type": "field"
      }
    ]
  },
  "stats": {}
}
</code></pre>
<p>核心在outbounds中，进行协议的配置与目标服务器ip与端口的配置，此处填入服务器内网ip与socks5端口即可。</p>
<p><img src="/blog-img/home-proxy/Untitled%2011.png" alt="Untitled"></p>
<h2>添加节点进行测试</h2>
<p>在入站列表进行对外节点的添加，选择vmess协议，端口随机生成，其余配置不变点击添加。</p>
<p><img src="/blog-img/home-proxy/Untitled%2012.png" alt="Untitled"></p>
<p>可以利用软件扫描节点二维码进行连接测试。</p>
<p><img src="/blog-img/home-proxy/Untitled%2013.png" alt="Untitled"></p>
<h2>DDNS</h2>
<p>在上述组件配置完成后，最后一步则是进行域名的配置，因为家宽的ip通常在72小时会进行重新拨号获取，因此采用DDNS用于动态域名解析，省去手动更换ip进行连接的使用成本。</p>
<p>本项目采用DDNS-Go进行动态解析。在Domains中填入需要动态解析的域名即可。IP变更通知配置可采用bark推送进行通知。</p>
<p><img src="/blog-img/home-proxy/Untitled%2014.png" alt="Untitled"></p>
<h2>路由器进行端口转发并通过域名进行节点连接</h2>
<p>在路由器后台针对单节点进行端口转发即可用域名加端口的方式进行节点连接。</p>
<p><img src="/blog-img/home-proxy/Untitled%2015.png" alt="Untitled"></p>
<h2>节点监控</h2>
<p>采用uptimekuma进行节点的监控，采用bark进行推送。</p>
<p>在xui新建一个加密的sock5节点（注意⚠️：这种方式并不安全），记录信息后在uptimekuma中进行创建新监控服务。</p>
<p><img src="/blog-img/home-proxy/Untitled%2016.png" alt="Untitled"></p>
<p><img src="/blog-img/home-proxy/Untitled%2017.png" alt="Untitled"></p>
<p>通过检测访问<a href="https://gstatic.com/generate_204">https://gstatic.com/generate_204</a>进行节点连通性的检测。</p>
<p><img src="/blog-img/home-proxy/Untitled%2018.png" alt="Untitled"></p>
<h2>节点通知</h2>
<p>采用bark进行节点状态的通知，关于推送配置可直接使用Uptime Kuma中的bark配置或BarkPush-Go进行多设备统一推送，此项目仍在完善中，请关注后续文章。</p>
<p><img src="/blog-img/home-proxy/Untitled%2019.png" alt="Untitled"></p>
<h2>服务器监控</h2>
<p>最后可以针对nas整体进行状态的监控，本项目采用哪吒探针进行监控。</p>
<p><a href="https://github.com/naiba/nezha">https://github.com/naiba/nezha</a></p>
<h2>最后的话</h2>
<p>生命不息，折腾不止。</p>]]></description><link>https://buycoffee.top/blog/tech/proxy</link><guid isPermaLink="true">https://buycoffee.top/blog/tech/proxy</guid><dc:creator><![CDATA[Hamster1963]]></dc:creator><pubDate>Sat, 15 Apr 2023 00:00:00 GMT</pubDate><enclosure url="https://buycoffee.top/blog-img/home-proxy/cover.jpeg" length="0" type="image/jpeg"/></item><item><title><![CDATA[Python SS Server]]></title><description><![CDATA[<h1>前言</h1>
<p>这个学年学校把宽带运营商从之前的联通大哥哥换成了如今的电信小弟弟，使用体验可谓一落千丈，最主要的原因是电信不再像联通提供拨号上网的账号和密码，改成了强制使用天翼校园的程序来进行认证上网，而且限制只能一台设备在线，这直接搞垮了宿舍内的一堆需要联网的设备，首先想到的是通过电脑先连接在共享给路由器，感谢这位兄弟提供的方法@<a href="https://blog.csdn.net/cbcrzcbc/article/details/108612445?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522161707454916780271558206%2522%252C%2522scm%2522%253A%252220140713.130102334..%2522%257D&#x26;request_id=161707454916780271558206&#x26;biz_id=0&#x26;utm_medium=distribute.pc_search_result.none-task-blog-2~all~top_click~default-5-108612445.pc_search_result_no_baidu_js&#x26;utm_term=%E5%A4%A9%E7%BF%BC%E6%A0%A1%E5%9B%AD">task138</a>，但是转念一想，既然学校内覆盖了校园网，那是否可以使用电脑搭建服务器从而达到在校园网范围内手机或者 iPad 都可以上网呢?这就是今天讨论的方法。</p>
<p><callout emoji="⚠️">此方法仅限交流，请勿在保密场所使用。</callout>
&#x3C;ProsCard
title="此项目可实现:"
pros={[
'自动获取本机局域网地址并写入配置文件。',
'自动获取本机局域网地址并写入配置文件。',
'开启时显示各项配置参数，帮助使用者配置代理软件。',
'开启SS代理服务器，出错时显示错误弹窗。',
]}
/></p>
<h2>原理与改进</h2>
<p>开启SS代理服务器部分参考了GitHub上的<a href="https://github.com/shadowsocks/libQtShadowsocks">shadowsocks-libqss</a>项目，需要使用者手动填写config文件，使用起来学习成本有一点高，且如果没有成功运行也没有错误提示，所以使用python编写一个小脚本改进一下，以达到上述的功能。</p>
<h2>使用方法</h2>
<h3>windows开启服务器</h3>
<p>想开启SS代理服务器十分简单，如果不想更改默认的端口、密码、加密方式，可以直接双击使用 开启SS代理服务器.exe，打开后根据弹窗配置自己的代理软件，安卓小飞机，苹果小火箭，windows使用clash，macOS使用surge。</p>
<p>一切顺利后应该是这样的界面</p>
<p><img src="/blog-img/python-ss-server/xkFOngzIiB5LEJG.png" alt=""></p>
<h3>ios使用小火箭连接</h3>
<p>在小火箭中点击右上角加号，类型选择Shadowsocks，根据配置弹窗中的信息，填入相关配置，混淆与插件为空。如图:</p>
<p><img src="/blog-img/python-ss-server/eWn7lYDuPB1XJ69.png" alt=""></p>
<h3>macos使用surge连接</h3>
<p>打开surge面板，在策略中选择添加</p>
<p><img src="/blog-img/python-ss-server/ID4YgcboJdkURFX.png" alt=""></p>
<p>填入配置信息，出站选择全局代理即可</p>
<p><img src="/blog-img/python-ss-server/mgIoqMwSjnZrKQ4.png" alt=""></p>
<p><img src="/blog-img/python-ss-server/SXMNRaEvmTlju4t.png" alt=""></p>
<p>surge教程到此结束</p>
<h2>程序编写</h2>
<p>我们的目标其实很简单，就是获取当前IP地址并且写入到config文件中，并在启动服务器时有弹窗包含主要配置信息提醒用户。</p>
<h3>获取ip地址</h3>
<p>这是一个很通用的获取本机局域网ip地址的方法，代码如下:</p>
<pre><code class="language-python">#获取实时IP地址
def get_host_ip():
    """
    查询本机ip地址
    :return: ip
    """
    try:
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        s.connect(('8.8.8.8', 80))
        ip = s.getsockname()[0]
    finally:
        s.close()
    return ip
</code></pre>
<h3>将ip地址写入配置文件</h3>
<p>配置文件的类型是json，在python中我们可以很轻松地修改json文件内的值，只需要知道key和value的对应关系即可，代码如下:</p>
<pre><code class="language-python">#将IP地址写入json文件

filename = 'config.json'
with open(filename, 'r') as f:
    data = json.load(f)
    data['server'] = get_host_ip() # &#x3C;--- 添加实时ip地址.

os.remove(filename)
with open(filename, 'w') as f:
    json.dump(data, f, indent=4)
</code></pre>
<p>由于上面的 get_host_ip()是直接返还ip地址的，所以在server的value部分我们直接使用get_host_ip()即可。</p>
<h3>读取基础配置信息</h3>
<p>在开屏弹窗中我们需要显示基本信息，所以我们先需要读取json文件内的我们需要的value值，代码如下:</p>
<pre><code class="language-python">#读取json 获取端口/密码/加密方式

filename = 'config.json'
with open(filename, 'r') as f:
    data = json.load(f)
    a = data['password']
    b = data['server_port']
    c = data['method']
</code></pre>
<h3>弹窗</h3>
<p>弹窗使用的是tkinter库，语法也十分简单，代码如下:</p>
<pre><code class="language-python">def talk():
    tkinter.messagebox.showinfo("提示","你的ip地址是" + get_host_ip()+"\n你的端口号是"+str(b)+"\n你的密码是" + a + "\n加密方式是"+ c +"\n请勿关闭cmd窗口")
</code></pre>
<p>实现界面如图</p>
<p><img src="/blog-img/python-ss-server/4lzSGF9ZgBIVTL3.png" alt=""></p>
<h3>主程序</h3>
<p>基本的小组件都写好了我们最后就把积木拼成小车车🚗就可以了,加入检测是否运行成功的弹窗，代码如下:</p>
<pre><code class="language-python">#主程序
netopen = threading.Thread(target=runserver)#, daemon=True)

if __name__=='__main__':
    netopen.start()
    if netopen.is_alive() == True:
        #弹窗显示
        talk()
        tkinter.messagebox.showinfo("欢迎","开启成功,如需退出请关闭cmd窗口")
    if  netopen.is_alive() == False:
        tkinter.messagebox.showinfo("退出","服务已退出，请检查配置")


</code></pre>
<h3>大功告成</h3>
<p>完整源码如下</p>
<pre><code class="language-python">import json
import os
import socket
import tkinter
import tkinter.messagebox
from threading import Thread
import threading

#初始化弹窗
root = tkinter.Tk()
root.withdraw()
root.wm_attributes('-topmost',1)

#获取实时IP地址
def get_host_ip():
    """
    查询本机ip地址
    :return: ip
    """
    try:
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        s.connect(('8.8.8.8', 80))
        ip = s.getsockname()[0]
    finally:
        s.close()
    return ip

#将IP地址写入json文件

filename = 'config.json'
with open(filename, 'r') as f:
    data = json.load(f)
    data['server'] = get_host_ip() # &#x3C;--- 添加实时ip地址.

os.remove(filename)
with open(filename, 'w') as f:
    json.dump(data, f, indent=4)


#读取json 获取端口/密码/加密方式

filename = 'config.json'
with open(filename, 'r') as f:
    data = json.load(f)
    a = data['password']
    b = data['server_port']
    c = data['method']

def runserver():
    os.system('shadowsocks-server.bat')
def talk():
    tkinter.messagebox.showinfo("提示","你的ip地址是" + get_host_ip()+"\n你的端口号是"+str(b)+"\n你的密码是" + a + "\n加密方式是"+ c +"\n请勿关闭cmd窗口")


#主程序
netopen = threading.Thread(target=runserver)#, daemon=True)

if __name__=='__main__':
    netopen.start()
    if netopen.is_alive() == True:
        #弹窗显示
        talk()
        tkinter.messagebox.showinfo("欢迎","开启成功,如需退出请关闭cmd窗口")
    if  netopen.is_alive() == False:
        tkinter.messagebox.showinfo("退出","服务已退出，请检查配置")
</code></pre>
<p>逻辑还是比较简单的，基本就是在调用不同的组件，但是成品还是很不错的，大大降低了学习和使用成本。</p>
<h2>最后想说的话</h2>
<p>这个方法对于校园网来说只能算是另辟蹊径，并没有完全从根本上解决问题，如果天翼校园的客户端能写得好一点，连接设备数能放宽一点，想必会受到更多学生的接纳。See you next time！欢迎评论！</p>]]></description><link>https://buycoffee.top/blog/tech/python-ss-server</link><guid isPermaLink="true">https://buycoffee.top/blog/tech/python-ss-server</guid><dc:creator><![CDATA[Hamster1963]]></dc:creator><pubDate>Tue, 30 Mar 2021 00:00:00 GMT</pubDate><enclosure url="https://buycoffee.top/blog-img/python-ss-server/cover.jpeg" length="0" type="image/jpeg"/></item><item><title><![CDATA[360 Router Hack]]></title><description><![CDATA[<h2>项目介绍</h2>
<p>360路由器目前没有无损刷机的办法，获取ssh终端权限较为困难，如想获取路由器状态信息以及连接设备信息就比较困难，但可以通过采用逆向破解网页登陆的形式进行后台的登录进行信息获取。</p>
<h2>项目地址</h2>
<p><a href="https://github.com/hamster1963/360-router-data-retriever">https://github.com/hamster1963/360-router-data-retriever</a></p>
<h2>实现效果</h2>
<p>采用代码进行360后台的登录，进行路由器信息的获取。</p>
<p><img src="/blog-img/router-hack/iShot_2023-04-29_16.34.57.png" alt="iShot_2023-04-29_16.34.57.png"></p>
<h2>登陆过程分析</h2>
<h3>抓包</h3>
<p>直接采用Safari的开发者工具进行抓包。</p>
<p>在输入框中输入管理员密码进行登录。</p>
<p><img src="/blog-img/router-hack/Untitled.png" alt="Untitled"></p>
<p><img src="/blog-img/router-hack/Untitled%201.png" alt="Untitled"></p>
<p>可以看到页面首先请求了get_rand_key.cgi（CGI是通用网关接口，是一种比较传统的动态网页的实现方式），通过Get方法获取到了一串随机的字符串。</p>
<p><img src="/blog-img/router-hack/Untitled%202.png" alt="Untitled"></p>
<p>在获取完成后进行一些暂时未知的处理后请求了web_login.cgi，请求数据中包括了管理员用户名与密码，可以发现pass密码中的值并不是我们所填入的密码值，首先猜测可能为md5或aes加密后的字符串。</p>
<p><img src="/blog-img/router-hack/Untitled%203.png" alt="Untitled"></p>
<p>查看登陆接口在登陆成功后的返还与Header。接口返还了登陆状态码以及Token-ID字段数据，同时在返回的Header中可以看到有Set-Cookie字段，猜测在后续的数据接口中应该会在请求头中携带token以及cookie进行权限验证。</p>
<p><img src="/blog-img/router-hack/Untitled%204.png" alt="Untitled"></p>
<p><img src="/blog-img/router-hack/Untitled%205.png" alt="Untitled"></p>
<p>查看后续请求头，可以观察到在Cookie中携带了先前返回的信息，包含Set-Cookie以及token信息。</p>
<p><img src="/blog-img/router-hack/Untitled%206.png" alt="Untitled"></p>
<p>目前为止，可以对登陆过程进行一个小总结。</p>
<p>输入账号密码→点击登录→获取随机字符串→进行加密→请求登录接口→获取Cookie以及token</p>
<p>目前主要难点为如何进行加密的逆向破解。</p>
<h3>逆向</h3>
<p>首先尝试md5加密，与目标密码不一致。</p>
<p>其次尝试简单粗暴的方式，查源码。查看js代码后，发现js代码进过webpack混淆打包，从函数名以及变量名中很难找出加密的代码块，因此使用关键词进行搜索。</p>
<p><img src="/blog-img/router-hack/Untitled%207.png" alt="Untitled"></p>
<p>果然还是简单粗暴比较有用，在搜索到AES关键词后，可以从代码中发现密码加密的关键逻辑</p>
<p><img src="/blog-img/router-hack/Untitled%208.png" alt="Untitled"></p>
<pre><code class="language-jsx">switch ((t.prev = t.next)) {
  case 0:
    return (
      (t.next = 2),
      c(0).then(function (t) {
        r = n || t
        var o = a.enc.Hex.parse(r.rand_key),
          u = a.enc.Latin1.parse('360luyou@install'),
          c = a.AES.encrypt(e, o, {
            iv: u,
            mode: a.mode.CBC,
            padding: a.pad.Pkcs7,
          })
        i = c.ciphertext.toString()
      })
    )
  case 2:
    return t.abrupt('return', r.key_index + i)
  case 3:
  case 'end':
    return t.stop()
}
</code></pre>
<pre><code class="language-jsx">switch ((t.prev = t.next)) {
  case 0:
    if (((r = e.substr(32, e.length - 32)), (i = {}), !n)) {
      t.next = 6
      break
    }
    ;(i.rand_key = n), (t.next = 9)
    break
  case 6:
    return (t.next = 8), c(0, e, !0)
  case 8:
    i = t.sent
  case 9:
    if (i.rand_key) {
      t.next = 11
      break
    }
    return t.abrupt('return', e)
  case 11:
    return (
      (u = a.enc.Hex.parse(i.rand_key)),
      (s = a.enc.Latin1.parse('360luyou@install')),
      (d = a.enc.Hex.parse(r).toString(a.enc.Base64)),
      (p = a.AES.decrypt(d, u, {
        iv: s,
        mode: a.mode.CBC,
        padding: a.pad.Pkcs7,
      })),
      t.abrupt('return', p.toString(a.enc.Utf8))
    )
  case 16:
  case 'end':
    return t.stop()
}
</code></pre>
<p>虽然变量名与函数名被混淆，但可以从代码中看到采用AES加密。</p>
<p>首先进行加密前的处理，对rand_key进行hex，定义iv，设置模式为CBC，padding为PKCS7。</p>
<p>加密步骤为：</p>
<ol>
<li>获取rand_key的后32位进行hex</li>
<li>对密码进行PKCS7Encode</li>
<li>最后以hex后的rand_key作为key，PKCS7Encode后的密码作为plainText，360luyou@install字符串进行hex后作为iv</li>
<li>进行AES加密</li>
</ol>
<p>在加密结束后，将前rand_key前32位与加密字符串进行拼接作为pass传输到路由器进行登录验证。</p>
<h2>代码实现</h2>
<p>这里给出Python与Go的加密示例。</p>
<pre><code class="language-python">def gen_aes_str(rand_key: bytes):
    mode = AES.MODE_CBC
    iv = b'\x33\x36\x30\x6c\x75\x79\x6f\x75\x40\x69\x6e\x73\x74\x61\x6c\x6c'  # "360luyou@install".decode('hex')
    encryptor = AES.new(rand_key, mode, iv)
    encoder = PKCS7Encoder()
    text = "password"  # password
    pad_text = encoder.encode(text)
    cipher = encryptor.encrypt(bytes(pad_text, "utf-8"))
    return cipher
</code></pre>
<pre><code class="language-go">// GenerateAesString 生成加密字符串
func (r *Router) GenerateAesString() (err error) {
	// 判断随机字符串是否为空
	if r.randStr == "" {
		g.Dump("randStr is empty")
		err := r.GetRandomString()
		if err != nil {
			g.Dump(err)
			return err
		}
	}
	// randKey := "fbf8a1ca3b31ace17adece7f6941a278017ff28b58200c5a153e07f5dc840b3f"
	decodeString, err := hex.DecodeString(r.randStr[32:])
	if err != nil {
		g.Dump(err)
		return
	}
	block, err := aes.NewCipher(decodeString)
	if err != nil {
		panic(err)
	}
	encryptor := cipher.NewCBCEncrypter(block, configs.DefaultAesIv)
	p7 := utils.PKCS7Encoder{BlockSize: 16}
	padded := p7.Encode([]byte(r.Password))
	cipherText := make([]byte, len(padded))
	encryptor.CryptBlocks(cipherText, padded)
	r.aesStr = hex.EncodeToString(cipherText)
	if r.aesStr == "" {
		g.Dump("aesStr is empty")
		return errors.New("aesStr is empty")
	}
	g.Dump(gtime.Now().String() + " Generate AESKey " + r.aesStr)
	return
}
</code></pre>
<h2>Go项目代码层级</h2>
<p>完整的登陆以及获取信息由Go代码进行编写。</p>
<p>首先定义不同模块的接口以及结构体</p>
<pre><code class="language-go">type LoginMethod interface {
	Login() error
}

type AesMethod interface {
	GetRandomString() error
	GenerateAesString() error
}

type Router struct {
	Address   string
	Password  string
	state     bool
	aesIv     []byte
	inHeaders map[string]string
	randStr   string
	aesStr    string
	token     string
	cookie    string
	Headers   map[string]string
}
</code></pre>
<pre><code class="language-go">type RouterMethod interface {
	GetRouterInfo() error
}
</code></pre>
<p>最后以嵌套接口的形式将不同模块组合起来。</p>
<pre><code class="language-go">type RouterController interface {
	LoginMethod
	AesMethod
	RouterMethod
}
</code></pre>
<p>在调用时，采用接口赋值的形式调用接口内的方法，使程序变得更加灵活且便于维护。</p>
<pre><code class="language-go">func main() {
	var routerMain internal.RouterController
	myRouter := internal.Router{
		Address:  configs.RouterAddress,
		Password: configs.RouterPassword,
	}
	routerMain = &#x26;myRouter

	err := routerMain.GetRandomString()
	if err != nil {
		g.Dump(err)
		return
	}
	err = routerMain.GenerateAesString()
	if err != nil {
		g.Dump(err)
		return
	}
	err = routerMain.Login()
	if err != nil {
		g.Dump(err)
		return
	}
	err = routerMain.GetRouterInfo()
	if err != nil {
		g.Dump(err)
		return
	}
}
</code></pre>]]></description><link>https://buycoffee.top/blog/tech/router-hack</link><guid isPermaLink="true">https://buycoffee.top/blog/tech/router-hack</guid><dc:creator><![CDATA[Hamster1963]]></dc:creator><pubDate>Sat, 29 Apr 2023 00:00:00 GMT</pubDate><enclosure url="https://buycoffee.top/blog-img/router-hack/cover.jpeg" length="0" type="image/jpeg"/></item><item><title><![CDATA[为站点增加更新提示]]></title><description><![CDATA[<h2>前言</h2>
<p>这篇博客我们将会讨论一下如何实现一个轻量级的站点更新提示。</p>
<h2>实现效果</h2>
<p>在站点存在新版本可用，而用户正在访问旧版本时，弹出一个侵入程度较低的更新提示，点击后刷新页面获取最新版本的站点。</p>
<h2>Demo</h2>
<updaterdemo>
<p>可点击更新按钮来体验效果，右上角的重置按钮将会使组件重新进行初始化渲染。</p>
<h2>架构</h2>
<p>先用一个简单的架构图来展示当前的更新模式。</p>
<p><img src="/blog-img/site-update/Frame_16.png" alt="Frame 16.png"></p>
<p>主要分为三部分：</p>
<ol>
<li>GitHub 通过 Web-hook 推送最新部署 ID 至服务器。</li>
<li>服务器获取到最新部署 ID 后存储到 Redis 中，并通过 SSE 分发给连接中的客户端。</li>
<li>客户端对比服务器下发 ID 与本地所持有的部署 ID ，如不匹配则弹出更新提示。</li>
</ol>
<p>接下来就分步骤简要拆解一下整体流程。</p>
<h2>构建 ID</h2>
<p>首先，需要一个标识来区分每个部署，既然代码由 Git 进行版本管理，那最符合直觉的方式当然就是直接使用 git SHA(Hash) 来作为唯一 ID。</p>
<blockquote>
<p>类似于保存已经编辑的文件，提交会记录对分支中一个或多个文件的更改。 Git 将为每个提交分配唯一的 ID，称为 SHA 或哈希，用于识别：</p>
<ul>
<li>具体的更改</li>
<li>进行更改的时间</li>
<li>更改创建者</li>
</ul>
</blockquote>
<h3>Next.js 的构建 ID</h3>
<p>在 next.js 中，我们可以通过 <code>next-build-id</code> 这个库来获取当前的 build id（也就是当前的 git SHA）。</p>
<p><a href="https://www.npmjs.com/package/next-build-id">npm: next-build-id</a></p>
<blockquote>
<p>By default, it will use the latest git commit hash from the local git repository</p>
</blockquote>
<p>next-build-id 实际上是通过获取本地 git 存储库来获取当前的 git SHA，因此在客户端页面上我们无法通过这个库来获取build id，只可以在构建的时候或者在服务器组件中获取。</p>
<p>在这个博客站点中，在服务器组件中获取 build id 后再将其传递给客户端组件，用以后续的对比。</p>
<pre><code class="language-tsx">import buildId from 'next-build-id'
import UpdaterInit from './updater'

export default async function UpdaterServer() {
  const BuildId = await buildId()
  return &#x3C;UpdaterInit buildId={BuildId} />
}
</code></pre>
<h3>GitHub 推送构建 ID</h3>
<p>当前博客部署在 Vercel 上，每次代码更新后都会触发 Vercel 的构建部署任务，而任务的状态实际上反映在仓库的 Deployment 中，因此我们可以通过监听 Deployment 的状态来获取最新构建部署成功的 ID。</p>
<p><img src="/blog-img/site-update/CleanShot_2024-11-03_at_01.49.392x.png" alt="CleanShot 2024-11-03 at 01.49.39@2x.png"></p>
<p>而监听的方式也十分简单，通过 GitHub 的 Webhooks 功能，可以在每次 Deployment 状态有变更时将相关状态与数据推送到设置的地址中。</p>
<p><a href="https://docs.github.com/en/webhooks">Webhooks documentation - GitHub Docs</a></p>
<p>在 <strong>Which events would you like to trigger this webhook?</strong> 设置中，只勾选</p>
<p><strong>Let me select individual events</strong> - <strong>Deployment statuses</strong></p>
<p><img src="/blog-img/site-update/CleanShot_2024-11-03_at_01.52.102x.png" alt="CleanShot 2024-11-03 at 01.52.10@2x.png"></p>
<p>设置后，每当部署状态有变更时，数据将会推送到设置的 Webhooks 地址中。</p>
<h2>博客后端</h2>
<p>在服务端，需要设计两个 API：</p>
<ol>
<li>接收 GitHub 的部署推送数据以获取部署状态与部署 ID API</li>
<li>提供客户端获取与订阅版本号更新 API</li>
</ol>
<h3>解析推送</h3>
<p>解析 GitHub 的部署推送数据十分简单，但首先需检查 Header 中的</p>
<p><code>x-github-event</code></p>
<p>是否为</p>
<p><code>deployment_status</code></p>
<p>在检查完成后，如</p>
<p><code>state</code> 为 <code>success</code></p>
<p>则便可以提取</p>
<p><code>deployment.sha</code></p>
<p>作为部署 ID</p>
<p><img src="/blog-img/site-update/api.go.png" alt="alt: 从推送 json 中解析部署ID"></p>
<p>在获取到构建ID后，将其存入到 Redis 缓存中，并通知订阅者。</p>
<h3>获取构建 ID API</h3>
<p>在获取 API 的实现上则较为简单，提供直接获取最新构建ID与订阅的功能即可，可以将这两个功能在同一个 API 中实现，通过 SSE 参数来区分是否为订阅请求。</p>
<pre><code class="language-go">func (c *ControllerV1) GetDeploy(ctx context.Context, req *v1.GetDeployReq) (res *v1.GetDeployRes, err error) {
	if !req.SSE {
		res, err = getBuildId(ctx)
		if err != nil {
			return
		}
	} else {
		request := g.RequestFromCtx(ctx)
		request.Response.Header().Set("Content-Type", "text/event-stream")
		request.Response.Header().Set("Cache-Control", "no-cache")
		request.Response.Header().Set("Connection", "keep-alive")
		notifier := g_functions.NotifierManagerInstance.GetOrSetCreateNotifier(g_consts.BlogBuildIdCacheKey)
		id, ch := notifier.Subscribe()
		defer notifier.Unsubscribe(id)
		return nil, c.sendBuildIdSSE(ctx, ch, request)
	}
	return
}
</code></pre>
<h3>客户端比对</h3>
<p>在 Next.js 中，通过构建一个更新器组件来实现更新的功能。</p>
<p>首先，在初始化组件时，首先通过 API 获取博客服务端记录的最新构建 ID，获取后将其作为订阅数据的 fallbackData ,这样子即可保证：</p>
<blockquote>
<p>获取到最新数据的同时也可以随时接收到新数据的推送。</p>
</blockquote>
<p><img src="/blog-img/site-update/api.go-2.png" alt="api.go-2.png"></p>
<p>获取到数据后，只需对比获取到的构建 ID 与客户端的构建 ID 是否一致即可得知是否为最新版本。</p>
<p>如版本不一致，则弹出更新提示。</p>
<p><img src="/blog-img/site-update/api.go-3.png" alt="api.go-3.png"></p>
<p>在更新提示的显示动画上，使用 <strong>framer-motion</strong> 动画库中的 <code>&#x3C;AnimatePresence/></code> 与 <code>&#x3C;m.div/></code> 来定义初始化与消失动画。</p>
<p>而在更新逻辑上，增加 <strong>canvas-confetti</strong> 来使得更新体验（刷新页面）有趣一些。</p>
<h2>灵感来源</h2>
<p>在使用 Clerk 与 V0 的过程中，在存在更新未保存的状态下，都采用了这种 banner 提示的方式来呈现。</p>
<p><img src="/blog-img/site-update/CleanShot_2024-11-03_at_23.35.262x.png" alt="CleanShot 2024-11-03 at 23.35.26@2x.png"></p>
<p><img src="/blog-img/site-update/CleanShot_2024-11-03_at_23.37.042x.png" alt="CleanShot 2024-11-03 at 23.37.04@2x.png"></p>
<p>这种良好的用户体验让我也想拙劣地模仿一番，于是便有了这个简易的更新提示。</p></updaterdemo>]]></description><link>https://buycoffee.top/blog/tech/site-update</link><guid isPermaLink="true">https://buycoffee.top/blog/tech/site-update</guid><dc:creator><![CDATA[Hamster1963]]></dc:creator><pubDate>Mon, 04 Nov 2024 00:00:00 GMT</pubDate><enclosure url="https://buycoffee.top/blog-img/site-update/cover.jpeg" length="0" type="image/jpeg"/></item><item><title><![CDATA[如何同步我在听什么/做什么]]></title><description><![CDATA[<p><callout emoji="⚠️">暂时停用。</callout></p>
<h2>前言</h2>
<p>在你看这篇文章的时候，可能会注意到顶部的头像旁边，有一个应用的图标和一个歌曲播放的展示。</p>
<p>图标实时显示了我正在我的 mac 上使用的软件，而音乐也与 macOS 中的 Apple Music 进行同步，显示歌曲，专辑封面和播放进度。</p>
<p><img src="/blog-img/sync/Untitled.png" alt="Untitled"></p>
<p>这篇文章就来说一下如何去实现这样的效果，顺便也从动画的角度说一下如何优化音乐组件中，鼠标hover 状态下专辑封面的动画效果。</p>
<p><img src="/blog-img/sync/Untitled.gif" alt="Untitled"></p>
<h2>架构</h2>
<p><img src="/blog-img/sync/Untitled%201.png" alt="Untitled"></p>
<ol>
<li>macOS 上运行 Go 后端服务，后端服务中注册了定时任务</li>
<li>定时任务通过 swift 语言编写的工具去获取所需的信息</li>
<li>获取信息后上报到部署在 Vercel 的 Go Runtime API</li>
<li>Go Runtime API 将数据缓存到 Redis 中</li>
<li>页面访问通过 Go Runtime API 获取对应的数据</li>
</ol>
<p>为什么要通过 Go 调用 swift 的根本原因就是： xcode 实在是.. 不太好用。</p>
<h2>获取当前使用应用程序</h2>
<p>通过 swift 语言可以很方便的使用macOS 的系统 API 来获取当前使用的程序名。</p>
<p>代码也很简单:</p>
<pre><code class="language-swift">import Cocoa

if let frontmostApp = NSWorkspace.shared.frontmostApplication {
    let appName = frontmostApp.localizedName ?? "Unknown"
    print("The active application is: \(appName)")
}
</code></pre>
<h2>获取当前收听的音乐信息</h2>
<p>在 macOS 语言中，直接获取当前播放音乐信息的 API 是私有的，因此如果需要获取信息的话需要通过 Apple Script 来获取, 脚本的部分我通过请教 GPT 老师来完成。</p>
<p><a href="https://chat.openai.com/g/g-uCkV6rsVh-swiftuigpt"></a></p>
<pre><code class="language-swift">let script = """
tell application "System Events"
    if (exists (application process "Music")) then
        tell application "Music"
            set currentTrack to current track
            set playerState to player state as text
            if currentTrack is not null then
                set trackName to name of currentTrack
                set artistName to artist of currentTrack
                set albumName to album of currentTrack
                set albumArtwork to raw data of artwork 1 of currentTrack
                set trackDuration to duration of currentTrack
                set playerPosition to player position
                return {trackName, artistName, albumName, albumArtwork, trackDuration, playerPosition, playerState}
            else
                return {"No track playing", playerState}
            end if
        end tell
    else
        return "Music not running"
    end if
end tell
"""
</code></pre>
<p>编写完脚本后，基础的信息可以很方便的获取，但是专辑封面的同步问题就会比较困难，因此我没有直接将整个专辑图片的数据包含在 swift 程序的返回中，而是将图片存储在 macOS 的某个目录下，Go 程序再通过目录去获取当前的专辑封面图。</p>
<pre><code class="language-swift">if let artworkData = descriptor.atIndex(4)?.data {
            let fileManager = FileManager.default
            if let downloadsDirectory = fileManager.urls(for: .downloadsDirectory, in: .userDomainMask).first {
                let fileURL = downloadsDirectory.appendingPathComponent("albumArtwork.jpg")  // 为图片选择一个文件名

                do {
                    try artworkData.write(to: fileURL)  // 将图片数据写入文件
                    artworkPath = fileURL.path  // 将文件路径保存在 artworkPath 中
                } catch {
                    standardOutput.capturedOutput = "写入文件错误: \(error)"  // 捕获错误输出
                }
            } else {
                standardOutput.capturedOutput = "无法访问文档目录"  // 捕获错误输出
            }
        }
</code></pre>
<h2>Go 程序上报数据</h2>
<p>上报数据对于 Go 语言来说是十分轻松的，我采用了老朋友 Goframe 框架作为基础框架进行数据的处理和上报，Vercel Go Runtime API 稍后介绍，上传部分简单介绍一下如何存储专辑封面的问题。</p>
<p>主要通过 GitHub API 上传专辑图片至 GitHub 仓库，访问时通过 jsDelivr CDN 来加速访问速度。</p>
<p>首先 Go 程序通过 swift 程序返还的路径读取专辑封面图转换为 base64 数据，然后通过 GitHub API 在 GitHub 仓库中 commit 并上传文件，专辑封面图就可以通过 CDN 进行访问了。</p>
<pre><code class="language-go">**// 将图像编码为 JPEG 格式并将结果写入缓冲区
	err = jpeg.Encode(&#x26;buffer, img, nil)
	if err != nil {
		log.Println("编码图像为 JPEG 格式时发生错误:", err)
		return
	}
	// 将缓冲区内容转换为 base64 编码的字符串
	baseString := base64.StdEncoding.EncodeToString(buffer.Bytes())
	// 构建 GitHub 上传参数
	imgFileName := albumName + artist + ".jpg"
	imgFileNameUrlEncode := url.QueryEscape(imgFileName)
	uploadJson := g.Map{
		"message": "upload: " + imgFileNameUrlEncode,
		"committer": map[string]string{
			"name":  "*",
			"email": "*@gmail.com",
		},
		"content": baseString,
	}
	uploadUrl := "https://api.github.com/repos/jmllx1963/MusicArtwork/contents/" + imgFileNameUrlEncode
	githubClient := g.Client().SetHeader("Accept", "application/vnd.github+json")
	githubClient.SetHeader("Authorization", "Bearer ghp_***")
	githubClient.SetHeader("X-GitHub-Api-Version", "2022-11-28")

	res, err := githubClient.Put(ctx, uploadUrl, gjson.New(uploadJson).String())**
</code></pre>
<h2>Vercel Go Runtime API</h2>
<p>因为在后端的数据保存上，对于 APP 与 Music 分别只需要两个接口（更新，获取）就可以完成功能，因此云函数的方式去部署是相对比较合适的，恰好 Vercel 的 Go Runtime 正在测试中，同时也可以很好的融合进前端项目中，因此采用了 Vercel 的云函数来构建。</p>
<p><a href="https://vercel.com/docs/functions/serverless-functions/runtimes/go">Using the Go Runtime with Serverless Functions</a></p>
<h3>缓存中间件</h3>
<p>因为云函数是没有持续的运行环境的，因此存储数据需要连接外部的组件来进行，我选择了 Redis 作为缓存中间件，通过 go-redis 库连接与操作 Redis。Redis 可以选择服务器安装 Redis 或者其他服务商提供的 Redis 服务，比如 Vercel 官方的KV服务或直接使用背后的提供商 Upstash。</p>
<p><a href="https://upstash.com/">Upstash: Serverless Data for Redis® and Kafka®</a></p>
<p>简略的完整 Vercel Go Runtime API 代码如下：</p>
<pre><code class="language-go">package get

import (
	"context"
	"github.com/redis/go-redis/v9"
	"net/http"
)

func Handler(w http.ResponseWriter, r *http.Request) {
	var ctx = context.Background()
	opt, _ := redis.ParseURL("redis://default:password@host:6379")
	client := redis.NewClient(opt)

	// 从缓存中获取
	macApp := client.Get(ctx, "mac-app").Val()
	if macApp == "" {
		macApp = "Offline"
	}
	resJson := `{"code": 200, "msg": "success", "data": {"app": "` + macApp + `"}}`
	w.Header().Set("Content-Type", "application/json")
	w.Header().Set("Access-Control-Allow-Origin", "*")
	w.WriteHeader(http.StatusOK)
	_, _ = w.Write([]byte(resJson))
	return
}
</code></pre>
<h3>（新）缓存中间件-Next.js API 实现</h3>
<p>除了使用 Go 语言去实现外，我们也可以直接通过 Next.js 的 Serverless Function 来实现，在接口中订阅 Redis 的频道来获取最新数据。为了优化用户体验，我们也会在 Redis 中缓存最新一条数据。</p>
<pre><code class="language-tsx">import { type NextRequest, NextResponse } from 'next/server'

export const runtime = 'nodejs'

// Prevents this route's response from being cached
export const dynamic = 'force-dynamic'

// Use ioredis to subscribe
import Redis from 'ioredis'

// Define the key to listen and publish messages to
const subKey = 'mac-music-now'
const lastKey = 'mac-music-last'
</code></pre>
<p>首先定义一些必要的头部信息，其中 dynamic 变量会告诉 vercel 永远不要缓存这个接口的数据。</p>
<pre><code class="language-tsx">export async function GET() {
  const encoder = new TextEncoder()
  // Use Redis to get the last message
  const lastMessage = await redisSubscriber.get(lastKey)
  // Create a stream
  const customReadable = new ReadableStream({
    start(controller) {
      if (lastMessage) {
        controller.enqueue(encoder.encode(`data: ${lastMessage}\n\n`))
      }
      void redisSubscriber.subscribe(subKey, (err) => {
        if (err) console.log(err)
      })
      // Listen for new posts from Redis
      redisSubscriber.on('message', (channel, message) => {
        if (channel === subKey)
          controller.enqueue(encoder.encode(`data: ${message}\n\n`))
      })
    },
  })
  // Return the stream and try to keep the connection alive
  return new Response(customReadable, {
    // Set headers for Server-Sent Events (SSE) / stream from the server
    headers: {
      'Content-Type': 'text/event-stream; charset=utf-8',
      Connection: 'keep-alive',
      'Cache-Control': 'no-cache, no-transform',
      'Content-Encoding': 'none',
    },
  })
}
</code></pre>
<p>我们使用 ReadableStream 来存储信息流，再通过 SSE 的形式返回。</p>
<h2>中段小结</h2>
<p>至此，有关 macOS 应用与音乐数据获取与存储的部分就完成了，接下来便是前端通过 API 获取数据然后展示在页面上。</p>
<h2>前端获取数据</h2>
<p>其实在获取数据的方式上，可以选择的有很多种，比如轮训，长连接，websocket，SSE，在本次项目中我选择 SSE 搭配 Redis 订阅发布的方式去实现。</p>
<p>关于流传输，可以参考 vercel 官方的文章:</p>
<p><a href="https://vercel.com/docs/functions/streaming">Streaming Data on Vercel</a></p>
<aside>
    👉 Vercel 中 node.js 环境下超时时间为10s,这意味着我们需要处理重连的逻辑。
</aside>
<p>我们用到 EventSource 来对上文中的 Next.js API 进行连接与获取数据流。</p>
<pre><code class="language-tsx">eventSource = new EventSource('/api/music')
</code></pre>
<p>同时我们手动处理收到数据后的解析与重连逻辑。</p>
<pre><code class="language-tsx">eventSource.onmessage = (event) => {
  const data = JSON.parse(event.data)
  setNowMusic(data)
}

eventSource.onerror = (event) => {
  console.error('EventSource failed:', event)
  eventSource!.close()
  // 设置延迟后重新连接
  setTimeout(connectToStream, 1) // 重连延迟为1毫秒
}
</code></pre>
<p>我们使用 useEffect 将这个 SSE 获取的功能挂载在组件上，完整代码如下:</p>
<pre><code class="language-tsx">const [nowMusic, setNowMusic] = useState&#x3C;MusicData>()
useEffect(() => {
  let eventSource: EventSource | null = null

  const connectToStream = () => {
    // 关闭现有的事件源（如果它还开着）
    if (eventSource) {
      eventSource.close()
    }

    eventSource = new EventSource('/api/music')

    eventSource.onmessage = (event) => {
      const data = JSON.parse(event.data)
      setNowMusic(data)
    }

    eventSource.onerror = (event) => {
      console.error('EventSource failed:', event)
      eventSource!.close()
      // 设置延迟后重新连接
      setTimeout(connectToStream, 1) // 重连延迟为1毫秒
    }
  }

  // 初始连接
  connectToStream()

  return () => {
    // 组件卸载时，关闭事件源
    if (eventSource) {
      eventSource.close()
    }
  }
}, [])
</code></pre>
<p>这样子前端就可以通过 SSE 获取源源不断的数据了，同时也避免了轮训带来的额外消耗。</p>
<p><img src="/blog-img/sync/Untitled%202.png" alt="Untitled"></p>
<h2>关于动画</h2>
<p>前端获取数据后，需要的就是将数据传入至组件中展示。</p>
<p>这里重点解析一下播放组件的动画显示逻辑。</p>
<p><img src="/blog-img/sync/Untitled%203.png" alt="Untitled"></p>
<p>播放组件主要展示专辑封面、歌曲名、播放状态与播放进度，其中播放进度以进度条的形式展示。</p>
<h3>专辑封面提取色彩</h3>
<p>在专辑封面图的四周，有依据图片而生成的颜色光晕，同时进度条的颜色也与光晕颜色一致。</p>
<p><img src="/blog-img/sync/Untitled%204.png" alt="Untitled"></p>
<p>这里通过一个函数可以提取图片的主题色：</p>
<pre><code class="language-tsx">const extractAverageColor = (img: HTMLImageElement): RGBColor => {
  const canvas = document.createElement('canvas')
  const context = canvas.getContext('2d')
  if (!context) {
    return { r: 0, g: 0, b: 0 } // Fallback color
  }

  canvas.width = img.width
  canvas.height = img.height
  context.drawImage(img, 0, 0, img.width, img.height)

  const data = context.getImageData(0, 0, img.width, img.height).data
  let r = 0,
    g = 0,
    b = 0,
    count = 0

  for (let i = 0; i &#x3C; data.length; i += 4) {
    r += data[i]!
    g += data[i + 1]!
    b += data[i + 2]!
    count++
  }

  return {
    r: Math.round(r / count),
    g: Math.round(g / count),
    b: Math.round(b / count),
  }
}
</code></pre>
<p>同时设置在每次获取到新专辑封面时重新提取</p>
<pre><code class="language-tsx">useEffect(() => {
  if (nowMusic?.artwork_url) {
    void fetch(nowMusic.artwork_url)
      .then((res) => res.blob())
      .then((blob) => {
        const img = new Image()
        img.src = URL.createObjectURL(blob)
        img.onload = function () {
          const color = extractAverageColor(this as HTMLImageElement)
          setImgColor(color)
        }
      })
  }
}, [nowMusic?.artwork_url])
</code></pre>
<p>光晕用 box-shadow 来绘制</p>
<pre><code class="language-tsx">style={{
     boxShadow: !isActive
     ? `0 0 10px 1px rgb(${imgColor?.r}, ${imgColor?.g}, ${imgColor?.b},0.6)`
     : `0 10px 50px 5px rgb(${imgColor?.r}, ${imgColor?.g}, ${imgColor?.b},0.3)`,
}}
</code></pre>
<p>进度条搭配播放进度，使用 background-image 搭配 width 来实现效果</p>
<pre><code class="language-tsx">style={{
backgroundImage: `linear-gradient(90deg, rgba(${imgColor?.r}, ${imgColor?.g}, ${imgColor?.b},0.05), rgba(${imgColor?.r}, ${imgColor?.g}, ${imgColor?.b},0.2))`,
width: `${nowMusic.play_percent}%`,
transition: 'width 0.5s ease-in-out',
}}
</code></pre>
<h3>播放组件初始化动画</h3>
<p><img src="/blog-img/sync/Untitled%201.gif" alt="Untitled"></p>
<p>有关动画的部分采用 framer-motion 去实现，只需定义动画参数，用"m.div"包裹元素即可。</p>
<pre><code class="language-tsx">const anim = {
  initial: { opacity: 1, scale: 0.3, transition: { delay: 1 } },
  closed: { scale: 1, y: 0, filter: ['blur(5px)', 'blur(0px)'] },
}
</code></pre>
<pre><code class="language-tsx">&#x3C;m.div
key={'music-widget'}
variants={anim}
initial={'initial'}
**>**
</code></pre>
<h3>专辑封面弹出</h3>
<p>当鼠标移动到播放组件上时，组件会消失，专辑封面由模糊渐渐变成清晰的专辑封面大图。</p>
<p>当鼠标移出播放组件，专辑封面消失，播放组件由模糊渐渐还原，最后加上回弹的效果增加真实性。</p>
<p><img src="/blog-img/sync/ScreenFlow.gif" alt="ScreenFlow.gif"></p>
<p>首先，我们需要确定触发范围，如果我们直接将触发范围绑定在播放组件卡片上，就有可能造成弹出专辑封面图后，鼠标已经不再触发范围内了，用户体验会大幅下降。</p>
<p>理念是来自于菜单中也很常见的二级菜单消失的范围判定，合理的做法应该是将触发范围扩展至鼠标到二级菜单底部。</p>
<p><img src="/blog-img/sync/Untitled%205.png" alt="Untitled"></p>
<p>而在本项目中，我采用以播放卡片为中心，向外增加 padding 的方式来确定触发范围。</p>
<pre><code class="language-tsx">&#x3C;div
      className="hit-area linkCursor pointer-events-auto relative py-4"
      onMouseEnter={() => setIsActive(true)}
      onMouseLeave={() => setIsActive(false)}
>
</code></pre>
<p>在播放器组件外部增加一个额外的 div 来绑定鼠标的触发，通过 py-4 在 y 轴上增加触发的面积。</p>
<p>剩下只需要定义好触发的动画参数即可。</p>
<pre><code class="language-tsx">const anim = {
  initial: { opacity: 1, scale: 0.3, transition: { delay: 1 } },
  open: {
    scale: 1,
    y: 80,
    filter: ['blur(5px)', 'blur(0px)'],
    opacity: [0.1, 1],
    transition: { duration: 0.4, ease: [0.23, 1, 0.32, 1] },
  },
  closed: { scale: 1, y: 0, filter: ['blur(5px)', 'blur(0px)'] },
}
</code></pre>
<p>在最外层用 AnimatePresence 包裹，确保可以触发进入与退出动画，同时将 mode 设置为 wait，这确保页面上只会出现一个动画组件。</p>
<pre><code class="language-tsx">&#x3C;AnimatePresence mode={'wait'}>
        {isMounted &#x26;&#x26; (
          &#x3C;m.div
            key={'music-widget'}
            variants={anim}
            initial={'initial'}
            animate={isActive ? 'open' : 'closed'}
            className={clsx(
              'pointer-events-auto relative mr-4 flex items-center rounded-xl bg-white backdrop-blur-lg dark:bg-black',
              {
                'gap-1 bg-opacity-10 px-1.5 py-1.5 ring-1 ring-zinc-900/5 dark:bg-opacity-10 dark:ring-white/10':
                  !isActive,
                'bg-opacity-0 px-2 py-2 dark:bg-opacity-0': isActive,
              }
            )}
          >
</code></pre>
<p>最终专辑动画的效果就实现了。</p>
<p>感谢阅读📖。</p>]]></description><link>https://buycoffee.top/blog/tech/sync</link><guid isPermaLink="true">https://buycoffee.top/blog/tech/sync</guid><dc:creator><![CDATA[Hamster1963]]></dc:creator><pubDate>Fri, 01 Dec 2023 00:00:00 GMT</pubDate><enclosure url="https://buycoffee.top/blog-img/sync/cover.jpeg" length="0" type="image/jpeg"/></item><item><title><![CDATA[为公司搭建VPN]]></title><description><![CDATA[<h2>目的</h2>
<p>公司原先购买 Astrill VPN 的付费账号给同事使用，优点在于登录即用，无需进行太多的配置，但是在墙越来越高的情况下，难免出现无法连接与间歇断线的问题。不仅运营和客服的同事表示严重影响了工作效率，同时每次出问题的时候我们也只能通过尝试更换连接不同的服务器来解决。</p>
<p><img src="/blog-img/vpn/Untitled.png" alt="Untitled"></p>
<p>在 Astrill 高昂的费用下，如果有一种节点可以由我们自己去监控调整，同时费用较低的方案就大好不过了。因此我直接将家用的方案稍加改进搬到了公司来，详细配置可以查看这篇文章：</p>
<p><a href="https://www.buycoffee.top/blog/home-proxy">家庭负载均衡节点部署 | Hamster</a></p>
<p>在新的方案下，全部的外网流量（如亚马逊，谷歌）都会经过中央的代理服务器后进行二次分流再转发至真正的服务器上，后面的流量传输与目前主流的科学上网方式几乎是一样的。</p>
<p>新的整体流量图可以参考如下：</p>
<p><img src="/blog-img/vpn/Untitled%201.png" alt="Untitled"></p>
<p>与旧 VPN 最大不同的在于节点的选择与流量的出口集中在了内网的服务器中，不仅可以统一根据员工进行流量管理也巧妙地绕开了 VPN 与机场对用户数的限制。</p>
<h2>采用软件</h2>
<p>在服务器上，全部服务部署在群晖 NAS 的容器环境中，加上动态的 DDNS 一共需要部署三个服务：</p>
<ol>
<li>DDNS-GO 负责域名的动态解析</li>
<li>x-ui 负责用户流量管理与转发至 v2raya</li>
<li>v2raya 负责最终的节点选择与流量转发</li>
</ol>
<p>在员工电脑具体应用的选择上，选择了 Clash for Windows 作为客户端，通过获取配置文件以连接到内网服务器。</p>
<h2>核心构建</h2>
<p>最核心需要解决的是配置下放到员工的问题，在使用旧 VPN 时，员工输入邮箱密码登录 VPN 后进行使用，而新版的有什么更好的解决办法直接下放配置到 clash 呢？</p>
<p>在经过一番调研后决定使用钉钉的宜搭搭建一个一键配置页面，员工通过打开页面获取配置，获取成功后点击一键配置直接进行 clash 的配置。</p>
<p><img src="/blog-img/vpn/Untitled%202.png" alt="Untitled"></p>
<h2>一键配置</h2>
<p>点击一键配置的时候会发生什么？主要会经历下面几个流程</p>
<ol>
<li>向后端申请用户配置</li>
<li>后端在 xui 中创建用户配置与端口</li>
<li>后端将 xui 生成的连接信息与 clash 基础配置文件结合</li>
<li>将配置文件连接变成 clash 配置短链返回至客户端</li>
<li>自动调用 clash 下载并启用配置</li>
</ol>
<p>接下来将会一步步介绍代码实现。</p>
<p>首先是创建配置接口的请求与返回结构体：</p>
<pre><code class="language-go">// CreateMemberProxyConfigReq 创建配置信息 Req请求
type CreateMemberProxyConfigReq struct {
	g.Meta    `method:"post" tags:"网络代理" summary:"创建配置信息" dc:"创建配置信息"`
	StaffName string `json:"staff_name"   v:"required #请输入 staff_name"    ` // 员工姓名
	StaffId   string `json:"staff_id"      v:"required #请输入 staff_id"   `   // 员工id
}

// CreateMemberProxyConfigRes 创建配置信息 Res返回
type CreateMemberProxyConfigRes struct {
	Id             uint        `json:"id"               ` // 网络代理成员表主键id
	StaffName      string      `json:"staff_name"       ` // 员工姓名
	StaffId        string      `json:"staff_id"         ` // 员工id
	ShortConfigUrl string      `json:"short_config_url" ` // 短链接
	ClashConfigUrl string      `json:"clash_config_url" ` // 代理软件配置URL
	OriConfigUrl   string      `json:"ori_config_url"   ` // 原始配置URL
	Port           int         `json:"port"             ` // 网络端口
	CreateTime     *gtime.Time `json:"create_time"      ` // 创建时间
	UpdateTime     *gtime.Time `json:"update_time"      ` // 更新时间
}
</code></pre>
<p>在后端接口中，首先进行的是 xui 面板用户的创建，需要传入端口，协议，密码等信息。通过抓取 xui 接口获取数据结构。</p>
<pre><code class="language-go">// NewProxy xui节点数据结构
type NewProxy struct {
	Up             int            `json:"up"`
	Down           int            `json:"down"`
	Total          int            `json:"total"`
	Remark         string         `json:"remark"`
	Enable         bool           `json:"enable"`
	ExpiryTime     int64          `json:"expiryTime"`
	Listen         string         `json:"listen"`
	Port           int            `json:"port"`
	Protocol       string         `json:"protocol"`
	Settings       ProxySettings  `json:"settings"`
	StreamSettings StreamSettings `json:"streamSettings"`
	Sniffing       Sniffing       `json:"sniffing"`
}
</code></pre>
<p>在创建用户完成后，需要反向将其中的信息转化为 Vmess 协议的链接。大体的结构如下：</p>
<pre><code class="language-go">type ConfigVmess struct {
	V    string `json:"v"`
	Ps   string `json:"ps"`
	Add  string `json:"add"`
	Port int    `json:"port"`
	Id   string `json:"id"`
	Aid  int    `json:"aid"`
	Net  string `json:"net"`
	Type string `json:"type"`
	Host string `json:"host"`
	Path string `json:"path"`
	Tls  string `json:"tls"`
}
</code></pre>
<p>通过生成 Vmess 链接方法，将生成的 Vmess与配置文件结合起来生成完整长连接：</p>
<pre><code class="language-go">// generateVmess 生成vmess链接
func generateVmess(proxy *g_consts.NewProxy) (vmessUrl, fullConfigUrl string) {
	newConfig := &#x26;g_consts.ConfigVmess{
		V:    "2",
		Ps:   proxy.Remark,
		Add:  "*.buycoffee.top",
		Port: proxy.Port,
		Id:   proxy.Settings.Clients[0].Id,
		Aid:  proxy.Settings.Clients[0].AlterId,
		Net:  proxy.StreamSettings.Network,
		Type: proxy.StreamSettings.TcpSettings.Header.Type,
		Host: "",
		Path: "",
		Tls:  "none",
	}
	// 构建Vmess链接
	vmessUrl = "vmess://" + gbase64.EncodeString(gjson.New(newConfig).MustToJsonString())
	// 转换成UrlEncode
	vmessUrl = gurl.Encode(vmessUrl)
	// 转换成配置文件URL
	baseUrl := "https://api.tsutsu.one/sub?target=clash&#x26;url="
	fullConfigUrl = baseUrl + vmessUrl + "&#x26;insert=false&#x26;config=https%3A%2F%2Fcdn.jsdelivr.net%2Fgh%2Flhl77%2Fsub-ini%40main%2Ftsutsu-mini-gfw.ini"
	return vmessUrl, fullConfigUrl
}
</code></pre>
<p>最后则是将长链接通过搭建在 cloudflare 上的短链服务变成短链接，加上 clash://的前缀便完成了整套生成配置的流程。</p>
<pre><code class="language-go">// 生成短链
	shortResp, err := g.Client().ContentJson().Post(ctx, g_consts.ShortUrlBaseUrl, g.Map{
		"url": config.ConfigUrl,
	})
	defer func(shortResp *gclient.Response) {
		err := shortResp.Close()
		if err != nil {
			glog.Warning(ctx, "关闭短链请求", err)
		}
	}(shortResp)
</code></pre>
<p>基于 Cloudflare worker 的短链服务可以参考下面这个仓库:</p>
<p><a href="https://github.com/xyTom/Url-Shorten-Worker">https://github.com/xyTom/Url-Shorten-Worker</a></p>
<p>最终我们会生成一个类似于这样的链接：</p>
<p>clash://install-config?url=<a href="https://url.*.*/TDHDJH">https://url.*.*/TDHDJH</a></p>
<p>在宜搭中点击一键配置时则会打开这个链接，而链接则会自动调用 clash 并下载启用配置文件。</p>
<p>通过这种方式，员工的学习成本相较于之前降低了不少，直接点击一个配置按钮即可完成科学上网的需求。</p>
<h2>仪表盘查看</h2>
<p>通过这种方式，将入口与出口统一在内网服务器，中央管理后，也可以方便地查看各项数据。</p>
<p><img src="/blog-img/vpn/Untitled%203.png" alt="Untitled"></p>
<h2>后记</h2>
<p>其实从一开始这只是一个简单的尝试，从 2 个同事开始使用，反馈一直不错，一直到现在有 20 多位同事采用这种新的科学上网方式，在几次的迭代后系统也趋于稳定，不仅降低了维护成本也提升了同事对外网访问的满意程度。所以我的加薪在哪里😭…..(完</p>]]></description><link>https://buycoffee.top/blog/tech/vpn</link><guid isPermaLink="true">https://buycoffee.top/blog/tech/vpn</guid><dc:creator><![CDATA[Hamster1963]]></dc:creator><pubDate>Mon, 13 Nov 2023 00:00:00 GMT</pubDate><enclosure url="https://buycoffee.top/blog-img/vpn/cover.jpeg" length="0" type="image/jpeg"/></item><item><title><![CDATA[Docker 容器自动更新]]></title><description><![CDATA[<p>在前几篇文章中，讨论了利用Github Actions针对Go项目进行构建与打包镜像，而本文章关注于在简单场景下，单体应用的自动检测镜像版本并在获取到新版本时自动更新并清除旧版本的镜像，这对于测试环境或个人开发者来说较为方便，也可以减少手动更新所带来的时间成本。</p>
<h2>实现效果</h2>
<p><img src="/blog-img/watchtower/Untitled.jpeg" alt="Untitled"></p>
<p>在Github打包上传镜像至DockerHub后，部署服务的服务器通过watchtower进行检测更新并拉取镜像进行替换更新。完成后进行消息推送的通知。</p>
<h2>Watchtower配置文件</h2>
<p>在所需部署的服务器上采用docker-compose启动watchtower服务，指定版本为1.5.3，在不指定的情况下，latest可能并不能拉取到最新版本的镜像。在<code>WATCHTOWER_NOTIFICATION_URL</code> 中，需要指明的是如果不是通过slack，discord等途径进行消息推送，而是通过http webhook的形式进行推送，则需要在URL前加上generic表明采用原生请求进行推送。</p>
<pre><code class="language-yaml">version: '3'
services:
  watchtower:
    image: containrrr/watchtower:1.5.3
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    command: --interval 15 --cleanup nasnetwork_push_go-push-go-1 push_go_docker-push-go-1
    environment:
      WATCHTOWER_NOTIFICATION_REPORT: 'true'
      WATCHTOWER_NOTIFICATION_TITLE_TAG: 'hamster'
      WATCHTOWER_NOTIFICATION_URL: 'generic+http://yourip:port/DockerUpdatePushCore'
      WATCHTOWER_NOTIFICATION_DELAY: 5
      WATCHTOWER_NOTIFICATION_TEMPLATE: |
        {{- if .Report -}}
          {{- with .Report -}}
        {{len .Scanned}} Scanned, {{len .Updated}} Updated, {{len .Failed}} Failed
              {{- range .Updated}}
        - {{.Name}} ({{.ImageName}}): {{.CurrentImageID.ShortID}} updated to {{.LatestImageID.ShortID}}
              {{- end -}}
              {{- range .Fresh}}
        - {{.Name}} ({{.ImageName}}): {{.State}}
            {{- end -}}
            {{- range .Skipped}}
        - {{.Name}} ({{.ImageName}}): {{.State}}: {{.Error}}
            {{- end -}}
            {{- range .Failed}}
        - {{.Name}} ({{.ImageName}}): {{.State}}: {{.Error}}
            {{- end -}}
          {{- end -}}
        {{- else -}}
          {{range .Entries -}}{{.Message}}{{"\n"}}{{- end -}}
        {{- end -}}
</code></pre>
<p>如不需要推送服务，则无需添加environment部分内容。</p>
<pre><code class="language-yaml">version: '3'
services:
  watchtower:
    image: containrrr/watchtower:1.5.3
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    command: --interval 15 --cleanup nasnetwork_push_go-push-go-1 push_go_docker-push-go-1
</code></pre>
<p>volumes指定系统下docker.sock的路径，而在command中进行绑定需要监测的容器与其他命令，在上述command中，</p>
<p><code>—interval</code> 指定每次检测的间隔秒数，15则为15秒</p>
<p><code>—cleanup</code> 指定在更新容器后清理旧容器与旧镜像</p>
<p><code>nasnetwork_push_go-push-go-1</code> 与 <code>push_go_docker-push-go-1</code> 都为容器名，可通过<code>docker ps</code> 进行查看，多个容器之间通过空格进行分割。</p>
<h2>部署</h2>
<p>首先通过 <code>docker-compose pull</code> 进行镜像的拉取</p>
<p>拉取完成后，通过<code>docker-compose up -d</code> 进行watchtower的启动</p>
<p>完成后即可通过查看日志获取检测结果信息。</p>
<p><img src="/blog-img/watchtower/Untitled.png" alt="Untitled"></p>
<h2>总结</h2>
<p>通过这几篇文章，可以以较小的维护成本与开发成本构建一套属于自己的CI/CD流程，利用Docker容器轻松进行容器的分发与部署。后续会继续介绍统一消息推送中心以及k8s与k3s的构建与部署。</p>]]></description><link>https://buycoffee.top/blog/tech/watchtower</link><guid isPermaLink="true">https://buycoffee.top/blog/tech/watchtower</guid><dc:creator><![CDATA[Hamster1963]]></dc:creator><pubDate>Wed, 24 May 2023 00:00:00 GMT</pubDate><enclosure url="https://buycoffee.top/blog-img/watchtower/cover.jpeg" length="0" type="image/jpeg"/></item></channel></rss>