邀请函项目改造实录
把 Spring Boot 五模块塞进一个 22MB 的 Go 二进制
代码仓库:https://github.com/kyyee/invitation/tree/v1.0.0
上周把一个 Spring Boot 老项目翻成了 Go。五个 Maven 子模块、一个 AngularJS H5 工程,最后塞进一个 22MB 的二进制。
改完最大的感受不是"快了多少"。是原来撑起这套体系的,一直是 Spring 的肌肉记忆,不是业务本身。
一、为什么要拆掉它
老项目是典型的"Java 中型团队遗产"。
根 pom.xml 串起五个 Maven 子模块。invitation-all 负责启动,invitation-core 装异常基类与 JSON 工具,invitation-db 用 EasyExcel 读 xlsx 喂给 H2,invitation-wx-api 暴露 REST 与微信签名,invitation-h5 是 AngularJS 1.x + Webpack 拼出的八屏轮播 H5。
能跑。但每一处都在悄悄罚你款。
启动一次要拉 Spring Boot 全家桶,镜像 300MB 起。H5 是另一个 npm 工程,前后端必须分两个静态目录服务。替换一份 Excel 要重启 JVM,等 BeanCopier,等 Hibernate warmup。想加一个字段,从 DTO 复制到 domain,再写一个 BeanCopier.create。
改一行代码,触动三个文件。这种项目最可怕的不是慢,是改动的边际成本太高。 它让团队本能地抵触任何需求变更。
但业务体量本身极小。19 个邀请对象,24 条行程,3 个 API 端点,一份微信签名。撑起这套体系的是 Spring 的肌肉记忆,不是业务的实际诉求。
方向变得清晰。把框架换成标准库,把模块换成 internal/,把 H5 编进二进制,把 Excel 挂到容器外。
二、标准库即框架
Go 圈有个常见误区,上手先选 gin 还是 echo。这次刻意没选。
理由很直白。路由只有四条,中间件只有三个(Logger / Recover / ProgramEnable),没有任何复杂参数绑定。引一个框架进来,换来的是它自带的 Context 类型污染、它对 http.Handler 接口的二次包装,以及未来每次升级都要重新对齐的 breaking change。
直接用 net/http + http.NewServeMux 写。中间件就是函数包装:
func Chain(h http.Handler, mws ...func(http.Handler) http.Handler) http.Handler {
for i := len(mws) - 1; i >= 0; i-- {
h = mws[i](h)
}
return h
}
四行代码,逆向包裹,最后一个中间件在最外层。没有 gorilla/mux,没有 chi,没有 echo。需要路径参数时,自己写 extractTail,七行解决。
这种克制换来的回报是,升级 Go 版本时几乎不会有任何框架兼容性问题。 新人接手第一眼看到的都是标准库签名。
internal/ 的强制封装
设计文档里把 internal/ 拆成 handler / service / model / repository / excel / cache 六层,看起来很 Java。但 Go 的 internal/ 不是 Java 的 package private,它是编译期强制。外部模块连 import 都做不到。
所有业务实现都被锁死在本模块内,对外只暴露 handler 注册的几个函数。
invitation-go/
├── cmd/
│ └── server/
│ └── main.go # 入口,配置加载 + 路由注册
├── internal/
│ ├── handler/ # HTTP 处理器,路由注册
│ ├── service/ # 业务逻辑
│ ├── model/ # 数据结构定义
│ ├── repository/
│ │ └── memory.go # map[string]Invitee 内存表
│ ├── excel/
│ │ └── loader.go # excelize 读取 + 数据清洗
│ └── cache/
│ └── ttl.go # sync.RWMutex + TTL 双检
├── web/ # 前端静态资源
│ ├── index.html
│ ├── css/
│ ├── js/
│ └── images/
├── Dockerfile
├── go.mod
└── go.sum
repository/memory.go 用 map[string]Invitee 和 map[string][]Meeting 直接持有数据,启动时从 Excel 一次性灌满,进程生命周期内只读不写。没有 H2,没有连接池,没有 ORM。
这种"内存即数据库"的取舍在低写入场景下极度舒适。 RWMutex 都不需要,因为根本没人写。
cache/ttl.go 是另一个被刻意写小的组件。sync.RWMutex + map[string]entry,每个 entry 带 expireAt。读时 double-check:先 RLock 查存在性与未过期,命中直接返回;过期则升级 Lock 重查一次,防止多个协程同时穿透。整套模式用在微信 access_token 与 jsapi_ticket 上,7200 秒 TTL,整个文件不到 80 行。
embed.FS:前端编译进二进制
老项目的 H5 是独立 npm 工程,部署时要 nginx 单独托管 dist/,再加一层反向代理。新方案用 //go:embed all:web 把整个 web/ 目录在编译期嵌入二进制:
//go:embed all:web
var webFS embed.FS
all: 前缀很重要。它强制嵌入以 . 或 _ 开头的文件,比如 _redirects、.well-known。微信域名验证文件 MP_verify_uM2MuMdCwg283VPG.txt 就是这种场景,缺了它微信侧 JS-SDK 鉴权过不了。
开发态用 --dev flag 切到本地文件系统读取,热重载无需重启二进制:
if cfg.Dev {
fs = os.DirFS("web")
} else {
fs, _ = fs.Sub(webFS, "web")
}
http.FileServerFS(Go 1.22+)直接吃 fs.FS 接口,统一两套路径。
一个二进制就是全部。部署从 docker pull + nginx conf + h5 tar 变成 docker pull。
ProgramEnable:初始化闸门
原 Java 有个 ProgramEnableInterceptor。Excel 加载失败时,所有 /api/ 请求返回 403。这个设计是对的。业务数据没准备好,API 就不该对外可见,否则微信侧拿到空数据会缓存进 JS-SDK 鉴权链路,造成连锁故障。
Go 版本用 atomic.Bool 实现:
type ProgramEnable struct {
ready atomic.Bool
}
func (p *ProgramEnable) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !p.ready.Load() && strings.HasPrefix(r.URL.Path, "/api/") {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
静态资源放行。/、/css/、/js/、/images/ 不受闸门影响。即使 Excel 加载失败,至少能看到 H5 框架,运维能第一时间确认服务进程存活,而不是看到一片空白以为是 nginx 挂了。
三、四场实战踩坑
TrimPrefix 的陷阱
上线前冒烟测试踩到一个隐蔽 bug。
请求实际打到 /api/kyyee/v2/invitation/invitee/1180121313,原代码用 strings.TrimPrefix(r.URL.Path, "/invitee/") 取 personNum。TrimPrefix 找不到 /invitee/ 这个前缀(因为实际前缀是 /api/kyyee/v2/invitation/invitee/),返回原字符串。于是 personNum 等于整条路径,下游 repository.FindByPersonNum 找不到,错误信息变成 invitee not found: api/kyyee/v2/invitation/invitee/1180121313。
这种 bug 在 Java 里几乎不会出现。 @PathVariable 在 Spring MVC 里是被 PathPatternParser 解析的,框架替你做了。Go 标准库的 http.ServeMux 在 1.22 之前甚至不支持路径参数。
下面是在 Go Playground 复现这个 bug 的截图:

修复方案是工具函数 extractTail。strings.Index 在完整路径里找 marker,找到后取尾部并 trim 斜杠。中文 mark(第1场出行行程)走 URL 编码进路径,url.PathUnescape 兜底解码。这套写法对 API 前缀变动免疫,前缀可以是 /api/foo/ 也可以是 /,marker 不变就行。
func extractTail(path, marker string) string {
idx := strings.Index(path, marker)
if idx < 0 {
return ""
}
return strings.Trim(path[idx+len(marker):], "/")
}
Excel 加载:从 EasyExcel 到 excelize 的暗坑
xuri/excelize/v2 是 Go 圈事实上的 Excel 库,但它的 API 哲学和 EasyExcel 完全不同。EasyExcel 是声明式的,给一个带 @ExcelProperty 注解的 POJO,它自动按表头映射。excelize 是命令式的,你要么按行迭代,要么按单元格取值。
第一版照着 EasyExcel 的语义写了,按列名取值,结果日期单元格返回 10/15/17 07:30 这种被 Excel 本地化过的字符串。原因在于 GetCellValue 默认走格式化路径,会套用 Excel 文件内置的 locale format。
修复方案是改用 RawCellValue: true 拿到序列号(一个 float64),再用 excelize.ExcelDateToTime 反推 time.Time,最后按业务期望的格式化:
raw, _ := f.GetCellValue(sheet, axis, excelize.Options{RawCellValue: true})
if serial, perr := strconv.ParseFloat(raw, 64); perr == nil {
if t, terr := excelize.ExcelDateToTime(serial, false); terr == nil {
return t.Format(layout)
}
}
return current
注意那个 excelize.Options{} 不是指针。第一次写成 &excelize.Options{} 编译器直接报"类型不匹配"。这是 Go 与 Java 风格差异最直观的一处,struct 值传递是常态,指针反而要刻意。
数据清洗规则严格对齐原 Java。departUnifiedTime 非空且 backTimeAndSchedule 含"待定"就替换为"待旅行社通知"。meeting.time 含 00:00:00 就替换为九个空格,保留 HTML 排版宽度。
这两条规则的存在本身就是技术债。 业务侧用空格当 padding,用字符串包含做判断。Go 版本忠实保留,不重新设计。
改造不是重构,是迁移。
微信签名:手写 SHA1 与 token 缓存
原 Java 用的是 weixin-java-mp,封装层很厚,但实际用到的只是 getJsapiTicket + createJsapiSignature。Go 版本全部手写:
raw := fmt.Sprintf("jsapi_ticket=%s&noncestr=%s×tamp=%d&url=%s",
ticket, nonce, ts, u)
sum := sha1.Sum([]byte(raw))
sig := hex.EncodeToString(sum[:])
四行核心逻辑。背后是 getAccessToken 和 getJSApiTicket 两个 HTTP 调用,各自带 7200 秒缓存、double-check 防穿透。整套文件不到 150 行,没有依赖任何微信 SDK。
app_id / app_secret 从环境变量优先读,YAML 兜底。镜像层不会留下凭证,Kubernetes Secret 挂载即可生效。
前端:AngularJS 退场,原生 ES6 上位
老 H5 用 AngularJS 1.x,ng-app / ng-controller / ng-repeat / ng-model,一整套双向绑定。AngularJS 早已停止维护,包体 60KB+,在这个八屏静态邀请函的场景里属于杀鸡用牛刀。
重写方案是原生 ES6 class + DOM 操作 + HTML5 Constraint API。表单校验从 ng-pattern 改成原生 pattern + checkValidity()。一个 touched 标志位防止页面初始态一片红,用户没碰过输入框就不显示校验错误:
if (!touched) return;
数据渲染从 ng-repeat 改成模板字符串 + innerHTML,配 escapeHtml 防 XSS。字段命名做双兼容,后端 snake_case 对齐原 Java Jackson SNAKE_CASE,前端 camelCase fallback:
const mark = invitee.meeting_mark || invitee.meetingMark;
Swiper 11 与 Animate.css 保留。它们解决的是真实问题,移动端轮播手势和 CSS 动画 keyframes 复用,自己重写不划算。animation-manager.js 完全保留,data-ani-name / data-ani-duration / data-ani-delay 驱动 Animate.css 的逻辑足够解耦,迁过来零成本。
四、22MB 背后的取舍
Docker 多阶段构建,三处细节值得点出:
FROM golang:1.22-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/invitation-server .
FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata && \
adduser -D -u 10001 app
COPY --from=builder /out/invitation-server /usr/local/bin/invitation-server
USER app
ENV TZ=Asia/Shanghai
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/invitation-server"]
CGO_ENABLED=0 纯静态链接,scratch 都能跑。选 alpine 是因为要 ca-certificates(微信 HTTPS 调用需要根证书)和 tzdata(日期格式化需要时区库)。
-trimpath -ldflags="-s -w" 去掉编译路径前缀和调试符号。22MB vs 32MB 的差距主要来自这里。
非 root 用户,adduser -D -u 10001 app + USER app,符合容器安全基线。
Excel 通过 volume 挂载,-v /data/excel:/data/excel:ro,--excel-path /data/excel/test.xlsx。替换后重启容器即重新加载,镜像层完全不感知数据变化。
| 维度 | 改造前 | 改造后 |
|---|---|---|
| 后端语言 | Java 17 + Spring Boot 3 | Go 1.22 + 标准库 |
| 模块数 | 5 个 Maven 子模块 | 1 个 Go module |
| 镜像大小 | ~300MB(JDK + jar) | 22MB 二进制 / ~30MB alpine 镜像 |
| 启动时间 | 3-5s(JVM warmup) | <100ms |
| 常驻内存 | ~250MB | ~20MB |
| 前端框架 | AngularJS 1.x + Webpack | 原生 ES6 + Swiper + Animate.css |
| 静态资源部署 | 独立 nginx + tar 包 | embed.FS 编译进二进制 |
| Excel 读取 | EasyExcel + 注解映射 | excelize + RawCellValue |
| 微信 SDK | weixin-java-mp | 手写 SHA1 + HTTP |
| 数据存储 | H2 内存表 | map[string]Invitee |
| 部署复杂度 | 2 个容器(API + H5)或 nginx 反代 | 1 个容器 |
这次改造最大的收获不是 22MB 这个数字,而是约束反推设计的过程。
Spring Boot 给你的太多了。自动配置、依赖注入、AOP、ORM、Actuator,每一项都是技术红利,但也都是隐性契约。一旦业务体量撑不起这些契约的复杂度,红利就变成利息。
Go 标准库的哲学恰好相反。net/http 给你一个 Handler 接口,剩下的全靠自己组合。这种"少即是多"的体验,会让团队重新审视每一个依赖。 真的需要 ORM 吗?真的需要 IoC 容器吗?真的需要 BeanCopier 吗?19 行数据,一个 map 就够了。
当然,标准库不是银弹。一旦业务复杂到要分库分表、要分布式事务、要消息队列,Java 生态的成熟度依然是压倒性优势。但在这个邀请函项目里,业务复杂度的天花板就是几张 Excel 表加三个 API。撑起它需要的不是 Spring,是一个能跑的 HTTP server。
框架不是问题,撑不起框架的业务才是。 当你发现一个 Spring Boot 项目里 19 行数据要配 5 个模块、3 层 DTO、2 个 BeanCopier,不是 Spring 错了,是你用错了锤子。
22MB 的二进制跑起来,/api/kyyee/v2/invitation/invitee/1180121313 一秒返回。看着日志里那行 [BOOT] excel loaded: 19 invitees, 24 meetings,会觉得这种克制本身就是一种工程美学。
至于那些被删掉的 Java 代码,它们完成了自己的历史使命,可以体面退场了。