一个“小问题”引起的重构实践与思考:全栈 Monorepo + MPA 网关
刚刚还沉浸在服务器架构大升级的喜悦中,想着之后可以愉快的开发一个又一个新应用。突然一个问题在脑中飘过:以后子项目越来越多,那我的包体积岂不是要爆炸?我之前虽然有了解过可以懒加载某些模块,但从来没有系统的考虑过这个问题。另外应用更新了怎么办,全部重启?模块下架怎么办,代码删除?感觉不对啊,耦合的太厉害了。我得考虑每个模块是不是得单独打包单独部署,每个应用都已独立的前后端。另外有一个入口程序,也有单独前后端。然后根据各个应用的运行情况,显示不同应用的入口。另外后面加的一些功能也可以都单独打包,例如刚做的 BBS。
这样虽然管理稍微麻烦点,但好处多多。文件体积不会爆炸了,更新互跳的问题解决了,下架模块也不会影响到其他模块,单个服务挂了不会影响到其他,等等。
经过一番深度复盘和架构调整,我总结了一套适合中长期发展的 “全栈 Monorepo + MPA 网关” 架构。
1. 账号系统:轻量级与安全性的平衡
在 H5 环境下,微信登录是核心。但为了调试和非微信环境,我们需要一套备用方案。
引继码/迁移码模式:参考日本应用软件(如 FGO)。用户无需注册即可分配 UID,通过生成随机的“迁移码”截图保存。这解决了“不想让玩家注册”与“防止数据丢失”的矛盾,成本极低。
解耦设计:数据库设计采用 User表(资产)与 Auth表(身份)分离。一个 User ID 可以绑定微信 OpenID、用户名或邮箱。
2. 前端架构:Monorepo 与模块复用
为了解决“重复造轮子”和“包体积过大”,我选择了 pnpm Workspaces。
目录结构
code
Text
/frontend
├── packages/
│ ├── shared/ # 核心逻辑:Hooks、通用组件、API 封装
│ ├── lobby/ # 大厅应用
│ ├── app-uno/ # 独立应用 A
│ └── app-bomb/ # 独立应用 B
├── pnpm-workspace.yaml
为什么是 pnpm + Vite?
pnpm:通过硬链接节省磁盘空间,在 Monorepo 中安装速度极快,彻底解决“幽灵依赖”。
Vite:对比传统的 create-react-app,Vite 极其轻量,毫秒级的热更新让开发体验提升了一个量级。
逻辑提取 (React Hooks):将金币管理 (usePlayer)、音效管理 (useAppSound) 封装成 Hooks。Hooks 不只是 Function,它自带 响应式数据更新 和 组件生命周期绑定。
3. 运行模式:MPA 还是微前端?
这是讨论中最重要的抉择。
初期结论:MPA(多页面应用)直接跳转。
理由:复杂交互应用非常吃内存,location.href 跳转能物理性地销毁旧页面内存,防止内存泄漏导致的浏览器闪退。
同源共享:通过 Nginx 或 CDN 将路径分配为 /apps/uno/。因为同源策略(协议、域名、端口一致),所有应用可以天然共享大厅的 LocalStorage。
后期预留:微前端(无界 Wujie)。
如果未来需要“大厅 BGM 不断”或“全局聊天弹窗”,再引入无界。无界基于 Iframe,对 Canvas 应用最友好,且子应用几乎零改动。(不怎么考虑了,最新的 react 我集成失败,暂时不想多花时间研究了)
4. 后端架构:Go 语言的工业化路线
后端同样采用 Monorepo,利用 Go Workspaces (go.work) 管理。
逻辑共享:建立 common 模块,存放数据库模型(GORM)、JWT 鉴权、Redis 初始化。
独立服务:每个应用模块是一个独立的 Go 进程,监听不同端口。
数据通讯:业务逻辑服务不直接读写核心资产表,而是通过内部 API 或 Redis 异步通知大厅服务进行结算,确保安全性。
5. 部署与网络优化
数据库:首选 PostgreSQL。它的 JSONB 类型非常适合存储用户状态、任务进度等非结构化数据,性能远超 MySQL 的 JSON 字段。
CDN 路由重写:利用 CDN 的 URI 重写 功能,将 /uno/* 的请求在 404 时重定向到 /uno/index.html。这让 OSS(静态对象存储)也能完美支持 SPA 路由。
静态资源缓存:Vite 产出的带 Hash 文件(如 main.d8a2.js)设置一年长缓存,而 index.html 设置 no-cache,确保更新即时生效。
运维红利:应用级热更新与灰度控制
得益于 MPA + 独立进程的设计,我实现了真正的“局部手术”。
前端热更新: 如果我要更新“业务 A”的 UI,我只需要构建 packages/app-bomb 并推送到 OSS 的 /apps/bomb/ 目录。大厅(Lobby)和其他子应用完全无感,用户甚至不需要重新登录大厅,只需在进入该模块时加载最新的 index.html。
后端灰度发布: 每个模块是独立进程,这意味着我可以单独重启其 Go 服务来修复逻辑 Bug。配合 Nginx 的 upstream 配置,我甚至可以同时运行 v1 和 v2 两个版本的服务进程,只让 10% 的用户先试用新版本,通过这种“小步快跑”的调优,极大降低了全线崩溃的风险。
6. 状态下沉:后端内存“脱敏”与 Redis 全量化
为了实现服务器集群的平滑扩展(Horizontal Scaling),重构后的后端遵循**“进程无状态”**原则。
内存清空: 所有的应用中间状态(如 UNO 的当前操作人、特定任务的倒计时、BBS 的临时热度)全部从 Go 进程的内存 map 或 slice 中移出,统一沉降到 Redis。
分布式锁与原子性: 涉及金币结算或多人竞速时,利用 Redis 的 SETNX 实现分布式锁,或者使用 Lua 脚本保证操作的原子性。这确保了当用户请求落在不同服务器实例上时,逻辑依然一致。
水平扩展的基石: 当后端内存全部 Redis 化后,任何一个应用服务都可以随时在 K8s 或多台虚拟机上开启副本(Replica)。网关(Nginx/Gateway)可以根据负载随意分发请求,而不需要担心“用户 Session 在 A 机器,请求却跑到了 B 机器”的尴尬。
2026-03-06 更新:经过实际压测与心智负担评估,我后来对“全量 Redis 化”进行了反思,转而采用了更轻量的“房间粘滞”架构。详见:为什么我最终放弃了分布式 Pub/Sub,选择了房间粘滞(Affinity)。
7. 总结:重构的心得
地基决定高度:文件夹路径的规划(同源策略)是数据共享的基石,比任何框架都重要。
简单即美:如果不需要全局社交,直接跳转比微前端更稳、更省内存。
Monorepo 是刚需:无论是 Go 还是 React,Monorepo 是管理多应用、多模块的唯一正解。
先跑通再优化:从基础业务流程开始,把这一整套 shared 逻辑跑通,比死磕复杂的特效更有意义。
“重构不是为了炫技,而是为了在未来增加第 100 个应用模块时,依然能像增加第 1 个应用时那样轻松。”