第二篇:为什么是 Go?插件架构怎么设计?

33 阅读5分钟
> Solo Workspace 的 3 个关键技术决策。

---

上篇讲了为什么要写 Solo Workspace——被碎片化工具折磨够了。这篇不讲故事,讲技术选型。

写 CLI 工具之前,先要回答三个问题:

1. **用什么语言?** — Go、Python、Node、Rust?
2. **架构怎么搭?** — 怎么让未来加功能不加复杂度?
3. **配置怎么管?** — 用户数据存在哪、怎么找?

每个问题都有不止一个答案。我选的未必是最优的,但一定是最适合我自己的。

---

## 一、语言选型:为什么是 Go

决定写 CLI 之后,我列了一个语言候选清单:

| 语言 | Python | Node | Go | Rust |
|------|--------|------|----|------|
| 单二进制 | ❌ | ❌ | ✅ | ✅ |
| 编译速度 | ✅ | ❌ | ✅ | ❌ |
| 开发效率 | ✅ | ✅ | ✅ | ❌ |
| 跨平台 | ❌ | ✅ | ✅ | ✅ |
| 生态成熟度 | ✅ | ✅ | ✅ | ❌ |

### Python 先出局

Python 我最熟,但分发太痛苦了。你要用户先装 Python、再 pip install、再面对各种版本冲突。更别说虚拟环境——一个 CLI 工具要用户搞 venv,体验太差了。

### Node 也出局

Node 比 Python 好一点,npx 能直接跑。但 `node_modules` 的体积让人崩溃,而且跨平台总有各种奇怪的问题。

### Rust 很心动,但排除了

Rust 的性能和安全性没话说,但我不是 Rust 开发者。为了写一个 CLI 从头学 Rust,开发周期至少翻三倍。我不想在第一个项目上同时挑战「写工具」和「学语言」两件事。

### Go 胜出

Go 的优势恰好踩中了我所有的需求点:

- **单二进制**`go build` 出来一个文件,扔到 `~/bin/` 就能用
- **交叉编译**`GOOS=windows GOARCH=amd64 go build` 一行命令打出 Windows 版本
- **零依赖运行**:编译出来的二进制不依赖任何运行时
- **开发效率**:goroutine、channel、标准库,写 CLI 足够用
- **Cobra**:Go 生态里最成熟的 CLI 框架,不用自己造轮子

最后还有一个私心:**我就想写 Go**。兴趣驱动的事,才容易坚持下去。

---

## 二、插件架构:像乐高一样拼

确定了语言,接下来想一个问题:**我肯定还会加新功能,怎么保证加了不把代码搞乱?**

常见的做法是写一个大 `cmd/` 目录,每个子命令一个文件。但这样下去,十几个命令全堆在一个包里的,早晚变成意大利面条。

我想要的是:**每个功能完全独立,互相不知道对方的存在。** 加一个新功能就是新建一个目录、写一个文件、注册一行代码。不想用了就删掉那行注册,干净彻底。

### 目录即插件

```
cli/go/
└── plugins/
    ├── server/ ───── server Cmd() → sw server
    │   └── plugin.go
    ├── ssl/ ──────── ssl Cmd() → sw ssl
    │   └── plugin.go
    ├── todo/ ─────── todo Cmd() → sw todo
    │   └── plugin.go
    ├── secret/ ───── secret Cmd() → sw secret
    │   ├── plugin.go
    │   └── crypto.go
    ├── env/ ──────── env Cmd() → sw env
    │   └── plugin.go
    ├── config/ ───── config Cmd() → sw config
    │   └── plugin.go
    ├── domain/ ───── domain Cmd() → sw domain
    │   └── plugin.go
    ├── notify/ ───── notify Cmd() → sw notify
    │   └── plugin.go
    └── project/ ──── project Cmd() → sw project
        └── plugin.go
```

每个目录就是一个插件包,暴露一个 `Cmd()` 函数,返回 `*cobra.Command`### 三步加一个插件

**第一步:创建目录和文件**

```
mkdir plugins/myplugin && touch plugins/myplugin/plugin.go
```

**第二步:实现 Cobra 命令**

```go
package myplugin

import (
    "github.com/spf13/cobra"
)

func Cmd() *cobra.Command {
    return &cobra.Command{
        Use:   "myplugin",
        Short: "My new plugin",
        RunE: func(cmd *cobra.Command, args []string) error {
            fmt.Println("Hello from my plugin!")
            return nil
        },
    }
}
```

**第三步:在 root 注册**

```go
// cmd/root.go
import "github.com/shenyb/solo-workspace/cli/go/plugins/myplugin"

func init() {
    rootCmd.AddCommand(myplugin.Cmd())
}
```

三步结束。编译、运行、收工。

### 为什么不做动态加载

有人可能会问:为什么不做成像 VS Code 那种动态加载插件?

因为我**不需要**。Solo Workspace 是给独立开发者用的,不是给企业用的。不会有人装几百个插件,不会有人热插拔。编译时静态链接就够用了,简单可靠,还不用写插件 SDK。

---

## 三、配置系统:数据在哪,用户说了算

CLI 工具绕不开一个问题:**配置和用户数据存在哪?**

最简单的方案:硬编码 `~/.solo/`。但这样有个问题——用户想用 `-c` 指定不同的配置怎么办?数据文件是跟着配置走,还是永远在 `~/.solo/`### 配置加载链

```
-c <path> 指定  → 最高优先级
~/.solo/config.yaml  → 全局默认
.solo.yaml (当前目录)  → 项目级配置
空默认值 → 兜底
```

先找 `-c`,没有再找 `~/.solo/config.yaml`,还没有就找当前目录的 `.solo.yaml`,全没有就返回空配置。

逻辑就 15 行,不多:

```go
func LoadConfig(path string) (*Config, error) {
    if path == "" {
        home, _ := os.UserHomeDir()
        homePath := home + "/.solo/config.yaml"
        if _, err := os.Stat(homePath); err == nil {
            path = homePath
        } else if _, err := os.Stat(".solo.yaml"); err == nil {
            path = ".solo.yaml"
        }
    }
    // 读取并解析 path
}
```

### 数据文件跟随配置

环境变量文件 `env.yaml` 和加密密钥文件 `secrets.enc` 存在哪?我没有硬编码到 `~/.solo/`,而是让它们跟随配置文件。

```go
func DataDir() (string, error) {
    if ConfigPath == "" {
        return filepath.Join(home, ".solo"), nil
    }
    return filepath.Dir(absPath), nil
}
```

简单说:**配置文件在哪,数据文件就在哪。**

- 默认 `~/.solo/config.yaml` → 数据在 `~/.solo/`
- `-c /path/to/project/config.yaml` → 数据在 `/path/to/project/`

这样用户可以把不同项目的数据分开,也可以用一个目录管理所有东西。**选择权在用户手上。**

### 共享状态的设计

还有一个细节:多个插件怎么共享数据?

我的做法很粗暴——**全局变量**```go
// internal/config.go
var CurrentConfig *Config
var ConfigPath string
```

每个插件的 `RunE` 直接从 `core.CurrentConfig` 读配置、改完写回去。不是什么优雅的设计,但够用。Go 的 `sync` 包加持下,并发安全也不是问题。

如果要吹毛求疵,这当然不是"最佳实践"。但对我来说,一个单用户的 CLI 工具,可读性和简单性比架构 purity 重要得多。

---

## 下期预告

前两篇讲了**为什么做****怎么做**。下一篇是最有料的一篇——**5 天 12 个 commit 的真实演进**- 实际使用发现的问题,编辑不方便 加ID
- todo越来越多,就归档
- 发现一个好用的方式:给AI agent直接调用sw todo list 出周报
- ...
---

> GitHub: [github.com/shenyb/solo-workspace](https://github.com/shenyb/solo-workspace)
>
> 上一篇:[# 受够了在 6 个工具之间来回切换,我花 5 天写了个 CLI](https://juejin.cn/post/7649652284339453952)

---