邀请函项目改造实录

邀请函项目改造实录

_

邀请函项目改造实录

把 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 挂到容器外。

graph LR subgraph Before["改造前 · 5 Maven 模块 + 独立 H5 工程"] direction TB A1["invitation-all<br/>Spring Boot 启动"] A2["invitation-core<br/>异常基类 + JSON 工具"] A3["invitation-db<br/>EasyExcel → H2"] A4["invitation-wx-api<br/>REST + 微信签名"] A5["invitation-h5<br/>AngularJS + Webpack"] A1 --> A2 A1 --> A3 A1 --> A4 A4 -.->|nginx 反代| A5 end subgraph After["改造后 · 1 个 Go 二进制 22MB"] direction TB B0["invitation-server"] B1["internal/handler<br/>路由注册"] B2["internal/service<br/>业务逻辑"] B3["internal/repository<br/>map 内存表"] B4["internal/excel<br/>excelize 读取"] B5["internal/cache<br/>TTL 双检缓存"] B6["web/<br/>embed.FS 嵌入"] B0 --> B1 B1 --> B2 B2 --> B3 B2 --> B4 B2 --> B5 B0 --> B6 end linkStyle default stroke:#666,stroke-width:1.5px

二、标准库即框架

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.gomap[string]Inviteemap[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 挂了。

graph LR R["HTTP 请求"] --> M1["Logger"] M1 --> M2["Recover"] M2 --> M3{"ProgramEnable<br/>ready?"} M3 -->|"/api/ 且未就绪"| F["403 Forbidden"] M3 -->|"放行"| H["Handler"] H --> S["Service"] S --> Repo["Repository<br/>map 只读"] S --> C["Cache<br/>TTL 双检"] style F fill:#ff6b6b,stroke:#c0392b,color:#fff style M3 fill:#f9ca24,stroke:#f39c12 style H fill:#6bcf7f,stroke:#27ae60 linkStyle default stroke:#666,stroke-width:1.5px

三、四场实战踩坑

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 的截图:

TrimPrefix bug 在 Go Playground 的复现

修复方案是工具函数 extractTailstrings.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&timestamp=%d&url=%s",
    ticket, nonce, ts, u)
sum := sha1.Sum([]byte(raw))
sig := hex.EncodeToString(sum[:])

四行核心逻辑。背后是 getAccessTokengetJSApiTicket 两个 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。替换后重启容器即重新加载,镜像层完全不感知数据变化。

xychart-beta title "镜像大小对比 (MB)" x-axis ["改造前 JDK+jar", "改造后 Alpine+二进制"] y-axis "大小 (MB)" 0 --> 350 bar [300, 30]
维度 改造前 改造后
后端语言 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 代码,它们完成了自己的历史使命,可以体面退场了。

管团队这几年,我最大的教训 2026-07-01

评论区