在做词焙查词接口时,如果词库不存在这个单词,则需要调用外部 API 更新词库,而目前用的大模型 API 调用时长较长(超过 5 秒)且不可控,故需要异步去做这件事。

这种场景很适合使用消息队列(RabbitMQ、Kafka、Redis PUB/SUB 等等),但为了使架构尽可能简单,不想轻易引入中间件,加上考虑到 Go channel 很适合在不同协程之间传递消息,于是使用 channel 封装了一个简易的消息队列:支持多队列、支持多消费者,而且整个封装才不到 300 行代码。

消息定义

消息队列,首先要有消息。最简单的一个消息就是 ID + 数据:

type Msg struct {
    ID   uint64
    Data any
}

消息 ID 用一个 uint64 类型的自增序列足矣。

队列定义

有了消息,接下来就可以定义队列了。

最简单的队列用一个 slice 用于保存消息,然再加上 EnqueueDequeue 即可:

type Queue struct {
    msgs []*Msg
}

func (q *Queue) Enqueue(msg *Msg) error
func (q *Queue) Dequeue() *Msg

但这样在并发的时候会出现问题,你可能会想到用一个 sync.Lock 来加锁,可行,但可以利用 chan 来实现无锁队列,具体原理是:新增 inout 两个 chan,然后在同一个协程里将 in 接收到的消息放入 msgs,用 out 来发送出队消息。

Queue 的定义如下:

阅读剩余部分

今天这篇与其说是月报,更像是年报 —— 不知不觉这是第 12 篇月报啦。

先是月报(2025.09.19~10.18),过去一个月做的东西并不多,大部分时间都花在了陪家人和思考,然后在零碎的时间主要是折腾 Homelab 服务器和路由器(又是跟 IPv6 折腾的时光),以及顺手迭代一下了 tipsy 的东西,抽空把 3 月份就说要写的文章给补上了:

再有就是更新了下简历,翻译了一份英文版出来。


阅读剩余部分

Go 在使用 os/exec 执行外部命令的时候,假如外部命令是持续输出的,该如何实时、持续获取外部命令的输出呢?

其实很简单,只需要通过 StdoutPipe() 创建一个管道接收外部命令的标准输出即可。

先来准备一个外部命令程序 ./hello/main.cc

#include "iostream"
#include <thread>

int main() {
    while (true) {
        auto now = std::chrono::system_clock::now();
        std::time_t now_c = std::chrono::system_clock::to_time_t(now);
        std::cout << "Current time is: " << std::ctime(&now_c) << std::flush;
        // sleep for a second
        std::this_thread::sleep_for(std::chrono::seconds(1));
    }
}

然后用 g++ 编译:g++ -o hello main.cc 得到 ./hello/hello,这个程序会每隔一秒输出当前时间。

接下来开始写我们的 Go 程序:

package main

import (
    "fmt"
    "os/exec"
    "strings"
)

func main() {
    cmd := exec.Command("./hello/hello")

    cmdStdout, err := cmd.StdoutPipe() // 创建一个管道接收外部命令的标准输出
    if err != nil {
        fmt.Println("Error creating StdoutPipe:", err)
        return
    }

    if err := cmd.Start(); err != nil {
        fmt.Println("Error starting command:", err)
        return
    }

    // 用协程非阻塞接收外部命令的流式输出
    go func() {
        for {
            buf := make([]byte, 1024)
            n, err := cmdStdout.Read(buf)
            if err != nil {
                fmt.Println("Error reading from stdout:", err)
                break
            }

            if n == 0 {
                continue
            }

            // 按需把尾部的换行符、或者是 '\0' (在 Go 里是 "\x00")去除掉
            output := strings.TrimSuffix(string(buf[:n]), "\n")
            fmt.Println("Output:", output)
        }
    }()

    if err := cmd.Wait(); err != nil {
        fmt.Println("Error waiting for command:", err)
        return
    }
}

不知不觉又过去一个月,上个月(2025.08.19~09.18)主要给 passport 新增了 OIDC 支持和给词焙做了一些迭代,然后更新了各种系统。

开发

  • 给 passport 新增了 OIDC 支持,逐步替代之前的简单粗暴、微服务式 SSO。也因为新增 OIDC,终于是给 passport 加上前端页面了,顺便再一次复习了 React Router
  • 给词焙新增了非法单词列表。查词接口如果单词不在数据库就会调 AI 接口去获取数据,但是如果用户给的本身就不是一个英语单词,那么无论查多少次都是没有数据的,既浪费时间又浪费接口费用,所以直接加了个非法单词列表,命中则直接返回报错了

其他

  • 将主路由从 RouterOS v6 升级到了 v7。宽带优惠套餐续期之后掉了公网 IP,在跟电信 argue 要回公网期间折腾了一下路由器,原因是在没有公网 IPv4 那几天把 IPv6 开了,然后发现 RouterOS v6 的 IPv6 有个比较蛋疼的问题,就是设备获取到的 IPv6 地址会越来越多,看到 V2EX 和恩山论坛有人讨论说 RouterOS RA 的默认 Preferred LifetimeValid Lifetime 是很长的(7 天和 30 天),而电信给的前缀是 3 天有效,一开始以为把 RouterOS 的 RA 生命周期缩短到不超过运营商给的时长就行,但是还是不够完美,因为我设置了路由器在凌晨定时重新拨号,而 ROS v6 在旧 IPv6 前缀失效时不会再通告,就会导致设备还在持有旧地址一段时间,这样造成的影响就是比如微信在切后台再回来的时候会先尝试用旧地址连接服务器,然后超时了再换新地址,“连接中...”的菊花就会转很久。后来又在 Reddit 和 Chiphell 看到有人提到说 RouterOS v7 新增了旧前缀失效时会通告,想了一下还是升级到 v7 了,至此双栈公网 IP 的宽带折腾告一段落。
  • 把一台东京的服务器从 Ubuntu 18.04 更新到 24.04 了,不知不觉这台机器连续工作了 1500+ 天没有重启过,并且还有个每天都在用的进程也跑了 3 年多没有因为内存泄漏之类的挂掉,这稳定性着实有点震惊到我了🤣。顺便也把 Homelab 一台 Ubuntu 20.04 的虚拟机更新到 24.04 了。

文章

在做词焙的非法单词列表的时候写了个简易可靠的 Set,具体实现:

问题出现

在做词焙词库更新的时候遇到一个问题:如果某一个单词是一个非法的单词,那就需要进行标记,之后再次遇到的时候可以直接跳过。

这个方案要实现的话,可能第一时间会想到用 Redis 的 Set;或者数据库里加一张表,一行一个非法单词。

但是词焙本身是没有用到 Redis 的,如果要用还得配置下内存淘汰策略;这么简单的需求放数据库的话又有点杀鸡用牛刀了。

所以我选择了直接使用内存 + 定期持久化到文件,整个技术方案不难,加起来就一百行左右的代码。

阅读剩余部分

🎉 Chrome 扩展「词焙+」上架

先广而告之一下,Chrome 扩展「词焙+」已经过审并且发布到 Chrome 应用商店了,欢迎戳这里安装体验~

开发

这个月的开发重心几乎全在词焙 Chrome 扩展上,做了一些局部重构的工作:

  • content_scripts 由纯手写 DOM 操作改为 React
  • 因为将整个插件所有页面都改成 React 之后打包出了一些奇奇怪怪的问题,于是改用 vite-plugin-web-extension
  • 开发扫码登录流程(浏览器插件、小程序、后端接口三端联动)
  • ...

踩了各种坑之后,Chrome 扩展开发初体验算是圆满结束

其他

这个月还抽空重装了一台应用服务器,然后把词焙 API 迁移了过去,并且接入了 EdgeOne CDN;

8 月初回老家参加好基友婚礼的时候顺便折腾了一下老家的网络,试了下 RouterOS 开启联通 IPv6 + DDNS6(为了给即将可能失去的电信公网 IPv4 做准备),结果就是不太理想,于是又折腾了私有部署 Tailscale,具体情况见下方新文章

文章