Vue
快来分享你的内容吧~
06-16 13:25
06-04 17:38从零构建在线Excel:一个Java全栈工程师的实战记录 我为什么要自己造这个轮子 说出来你可能不信,起因是公司内部一堆Excel文件满天飞。 财务部的预算表、运营部的数据看板、产品部的需求矩阵——每一次改一个数,就要在微信上重新传一遍文件。文件名从"最终版"进化到"最终最终版"再到"打死也不改了版",像极了程序员给变量起名。 市面上不是没有在线表格产品,腾讯文档、飞书表格都挺好用。但公司内网环境查看全文加油鸭:太硬核了!分块存储+前端导出的思路既巧妙又实用,代码可移植性更是直击开发者痛点,为你这份扎实的全栈实践点赞!442070分享- 04-29 17:25·全栈开发我有一个员工tip组件,我的实现是 张三 如果我用指令封装下 v-userid='12345' 调取下组件 这种方式合规吗 有啥歧义?查看全文青碧凝霜:除了大厂以外,不建议考虑这个可读性,可维护性没问题就可以231分享
- 02-27 14:32
- 2025-12-29考完研啦!虽然结果未知,但总算能松口气,把精力放回自己的小项目上了。今天想和大家分享一下我一直在做的「云图库」。这段时间偷偷给它加了不少功能,当然也引入了不少新 bug 😂。我的想法很简单:在有限的资源里,做一个体验还不错的小玩意。目前的计划是「先解决有没有,再解决好不好」。按照这个节奏,估计至少还需要三个月才能把基础功能都搞定。现在功能拓展得有点杂,先分享几张界面截图,希望能给同样在做小项目的查看全文加油鸭:考完研立刻回归热爱的项目,这份专注和热情太打动人了!云图库界面清爽功能实用,一步步把想法落地真的很酷。坚持“先有后优”的节奏,稳扎稳打一定会越来越棒,期待开源后一起学习!201311分享
- 2025-11-30·Java后端一个用 Vue 3 + Vite 构建的交互式布局实验室,模拟并扩展 LeetCode 编辑页的多面板体验。支持面板分组、拖拽重排、分屏拆分、布局预设切换与面板尺寸调整,可作为在线刷题或多区块交互界面的参考实现。查看全文加油鸭:这个项目太棒了!多面板交互设计得很细腻,尤其是拖拽和拆分逻辑的处理,看得出对用户体验的极致追求。期待看到更多应用场景的扩展!210分享
我做了一个轻量级定时任务平台 ChronoFlow
最近我做了一个轻量级的定时任务平台,名字叫 **ChronoFlow**。 它的定位很简单:**给内网单团队使用的轻量级任务调度系统**。 如果你只是想管理几十个定时任务,不想上太重的平台,又希望有 Web 页面、执行日志、手动运行、Cron 配置、任务终止、Docker 部署这些能力,那么 ChronoFlow 可能会比较适合。 项目地址: ```text https://github.com/Honghuaijie/chronoFlow ```  ## 为什么做这个项目 在很多中小型团队或者个人项目里,经常会有一些定时任务需求,比如: - 每隔几分钟同步一次数据 - 每天凌晨跑统计脚本 - 定时清理临时数据 - 定时调用 Python 脚本生成报表 - 手动触发某个后台任务 - 查看任务执行日志和失败原因 这些需求一开始可能直接写在 Linux crontab 里。 但任务一多,问题就来了: - 不方便查看有哪些任务 - 不方便手动运行 - 不方便看执行日志 - 任务失败了不直观 - 多台机器执行脚本不好管理 - 想终止正在运行的任务比较麻烦 - 非运维同学不方便操作 所以我做了 ChronoFlow,希望它足够轻量,但又能覆盖日常任务调度的大部分场景。 ## ChronoFlow 是什么 ChronoFlow 主要由三个部分组成: ```text UI -> Admin -> Exec ^ | | v +-- callback ``` - **chronoFlow-ui**:调度中心前端页面 - **chronoFlow-admin**:调度器后端,负责任务、执行器、日志、调度逻辑 - **chronoFlow-exec**:执行器后端,负责真正执行 Shell 脚本 其中只有 Admin 连接 MySQL,Exec 不连接数据库。 Exec 执行完任务后,会通过 callback 回调 Admin,把执行结果、退出码、日志信息写回来。 ## 目前支持的功能 ChronoFlow 第一版主要支持这些功能。 ### 1. 执行器管理 可以在页面中新增、编辑、删除执行器。 执行器会有在线/离线状态,Admin 会定期检查执行器健康状态。 如果 Admin 和 Exec 都在 Docker Compose 里部署,执行器地址可以填写: ```text http://chronoflow-exec:10004 ```  ### 2. 任务管理 支持创建任务、编辑任务、删除任务、启动调度、停止调度、手动运行。 任务可以绑定到某一个执行器上。 同一个任务默认不允许并发运行,避免同一个脚本重复执行导致数据问题。  新增任务时,可以选择执行器、配置 Cron 表达式、设置超时时间和任务说明。  ### 3. Cron 可视化配置 页面提供了 Cron 配置弹窗,支持常见的分钟、小时、日、周、月配置,也支持手动输入 Cron 表达式。 同时会展示接下来几次运行时间,方便确认表达式是否符合预期。  ### 4. Glue Shell 每个任务可以维护一段 Glue Shell 脚本。 例如: ```bash #!/bin/bash set -e echo "hello chronoflow" echo "run time: $(date '+%Y-%m-%d %H:%M:%S')" python3 --version echo "done" ``` 如果你的脚本比较复杂,也可以把 Python 文件挂载到执行器容器里,然后在 Glue Shell 里调用: ```bash python3 /scripts/report.py ```  ### 5. 异步执行和回调 Admin 下发任务后不会一直阻塞等待结果。 Exec 会异步执行脚本,执行完成后回调 Admin。 如果 Admin 临时重启或不可用,Exec 会把待回调结果临时落盘,后续继续重试。 ### 6. 任务终止 对于长时间运行的任务,可以在页面上点击终止。 Exec 会尽量终止整个进程组,而不是只 kill 主进程。 这对 Shell 脚本里再启动 Python、子进程的场景比较重要。 ### 7. 执行日志 MySQL 只保存日志元数据,完整日志正文保存到文件里。 这样可以避免把大量 stdout/stderr 直接塞进 MySQL,后续日志增长也更好处理。 日志里可以看到: - 执行状态 - 开始时间 - 结束时间 - 执行耗时 - exit code - 错误信息 - Glue 快照 - 文件日志内容  ### 8. 运行报表 目前也做了一个简单的运行报表页面,可以看到: - 任务数量 - 调度次数 - 执行器数量 - 最近执行成功/失败比例 - 近 7 天执行趋势  ## 部署方式 ChronoFlow 支持两种 Docker 部署方式。 ### 源码构建部署 适合开发者自己修改代码后构建: ```bash git clone https://github.com/Honghuaijie/chronoFlow.git chronoflow cd chronoflow/deploy cp .env.example .env docker compose -f docker-compose.mysql.yml up -d docker compose up -d --build ``` ### 作者镜像部署 如果服务器空间比较小,不想拉完整源码,可以只复制 `deploy` 目录,然后使用已经发布的镜像: ```env CHRONOFLOW_ADMIN_IMAGE=ghcr.io/honghuaijie/chronoflow-admin:v0.1.2 CHRONOFLOW_EXEC_IMAGE=ghcr.io/honghuaijie/chronoflow-exec:v0.1.2 CHRONOFLOW_UI_IMAGE=ghcr.io/honghuaijie/chronoflow-ui:latest ``` 启动: ```bash docker compose -f docker-compose.image.yml up -d ``` 默认访问地址: ```text http://127.0.0.1:5173 ``` 默认账号: ```text admin / admin123 ``` 生产环境需要修改默认密码、JWT Secret、Callback Token、执行器 Token 和数据库密码。 ## 技术栈 后端主要使用 Go。 整体结构分为: - API 层 - Service 层 - Biz 层 - Data 层 - Worker / Scheduler - Executor Client - Log Store 前端是一个 Web 调度中心,主要面向 PC 页面,不追求移动端复杂适配。 部署使用 Docker Compose,MySQL 单独拆成一个 compose 文件,避免频繁构建业务镜像时影响数据库容器。 ## 适合什么场景 ChronoFlow 当前更适合: - 内网环境 - 单团队使用 - 几十个以内任务 - 单调度器 - Shell / Python 脚本调度 - 希望轻量部署 - 希望有 Web 页面和日志查看 它暂时不定位为大规模分布式任务调度平台,也不追求复杂的多租户、权限体系和海量任务调度能力。 ## 第一版的一些取舍 第一版我做了一些偏轻量的选择: - 单调度器 - MySQL 保存元数据 - 日志正文保存文件 - Exec 不连接数据库 - 全局 callback token - 同一个任务不允许并发运行 - Linux 优先,任务终止基于进程组 - Docker 部署优先 这些选择不是为了“做少”,而是希望系统更容易部署、理解和维护。 ## 当前状态 目前 ChronoFlow 第一版已经完成了核心功能,并且已经做过本地和线上 Docker 部署验证。 线上验证过的链路包括: ```text UI -> Admin -> Exec -> Glue Shell -> Callback -> 执行日志 ``` 也就是说,从页面创建任务、手动运行、执行器执行脚本、回调结果、查看日志,这条主链路已经跑通。 ## 后续计划 后续可能会继续优化这些方向: - 添加任务失败提醒 - 更完善的部署文档 - 更好的日志降噪 - 更细的报表统计 - 更友好的 Cron 配置体验 - 更多执行器运行环境示例 - GitHub Release 和镜像版本管理 - 更完善的错误提示 ## 总结 ChronoFlow 是我做的一个轻量级定时任务平台,目标不是替代大型调度系统,而是解决一些更日常、更直接的内网任务调度问题。 如果你也有类似需求,比如想把 crontab、Shell 脚本、Python 脚本统一放到一个 Web 平台里管理,可以试试这个项目。 项目地址: ```text https://github.com/Honghuaijie/chronoFlow ``` 欢迎体验、反馈和交流。
SpaceDt卫星轨道可视化与空间态势感知平台
> 基于 **Cesium + Vue 3 + KeepTrack API v4** 的卫星轨道实时可视化与空间态势感知平台 ## 项目概述 SpaceDt 是一个功能完整的卫星轨道可视化与空间态势感知平台,集成了 TLE 数据解析、开普勒轨道传播、三维地球渲染、传感器覆盖分析、星地链路仿真、全坐标系转换、卫星编目监控、交会预警、发射预告、空间指标驾驶舱、文本数据导出等 8 大核心模块,适用于卫星轨道设计、任务规划与态势展示场景。 ### TLE 数据解析  ### 卫星运行仿真  ### 通信链路视窗  ### 坐标转换  ### 空间态势感知  ### 发射预告  ### 空间指标  ### 文本数据  ## 技术栈 | 分类 | 技术 | | --------- | -------------------------------------------- | | 前端框架 | Vue 3 (Composition API) | | 三维引擎 | Cesium 1.126+ | | UI 组件库 | Element Plus | | 路由 | vue-router 4 | | 语言 | TypeScript 5.7 | | 构建工具 | Vite 6 | | 轨道计算 | satellite.js / ootk v6 / 自研开普勒解算器 | | 数据源 | KeepTrack Space API v4 (2000次/小时免费配额) | | 3D 模型 | glTF 2.0 (weixing.gltf / dish.gltf) | --- ## 快速开始 ```bash # 安装依赖 npm install # 启动开发服务器 npm run dev # 生产构建 npm run build ``` 启动后访问 `http://localhost:5173`,通过顶部导航栏切换八个功能模块:TLE 数据平台、卫星运行仿真、通信链路视窗、坐标转换、空间态势感知、发射预告、空间指标、文本数据。 --- ## 功能模块 ### 一、TLE 数据平台 (`/tle`) 负责卫星 TLE 数据的管理、解析与轨道可视化。 #### 1. TLE 数据源 | 数据来源 | 说明 | | ------------------- | -------------------------------------------------- | | **Celestrak** | 按卫星分组(空间站、气象、科学等)一键拉取实时 TLE | | **Space-Track.org** | 支持账号登录,按 NORAD ID 或分类查询历史 TLE | | **文件上传** | 支持`.tle` / `.txt` 格式本地文件上传 | | **手动粘贴** | 粘贴标准三行 TLE 格式(名称 / Line1 / Line2) | | **内置示例** | 预置 ISS、NOAA-19 等常用卫星 TLE 数据 | #### 2. 轨道可视化 - 基于 SGP4 传播算法,实时计算卫星在轨位置 - Cesium 三维地球上渲染轨道弧线 - 卫星列表支持选中高亮与视角跟踪 - 轨道参数面板展示 TLE 7 参数(倾角、升交点赤经、偏心率等) #### 3. 碰撞检测 - 支持两两卫星间的最小距离计算 - 碰撞概率评估与预警显示 --- ### 二、卫星运行仿真 (`/simulation`) 完整的多卫星轨道动力学仿真与传感器覆盖分析演示。 #### 1. 卫星轨道与运动仿真 系统预置四颗卫星,每颗具有独立轨道与可视化配置: | 卫星 | 半长轴 (km) | 倾角 | 偏心率 | 轨道颜色 | 特殊功能 | | ------- | ----------- | ---- | ------ | -------- | --------------------------- | | 卫星1号 | 9678.145 | 20° | 0.1 | 黄色 | 近地点/远地点标记 | | 卫星2号 | 9678.145 | 60° | 0.0 | 蓝色 | 多层金字塔传感器(8层叠加) | | 卫星3号 | 10878.145 | 40° | 0.2 | 红色 | 星下点轨迹 + 卫星垂线 | | 卫星4号 | 10078.145 | 80° | 0.05 | 绿色 | 单层金字塔传感器 | - 所有卫星使用 **weixing.gltf** 3D 模型可视化 - 基于开普勒轨道六要素的二体问题实时解算 - 统一 ECI(地心惯性)坐标系,卫星与轨道线精确对齐 #### 2. 传感器与载荷仿真 | 类型 | 搭载卫星 | 参数 | 效果 | | ---------------- | -------- | ------------------------ | --------------------------------------------- | | **锥形传感器** | 1号、3号 | 半角 15°,高度 4000km | 青色线框 + 半透明锥形填充(CylinderGraphics) | | **金字塔传感器** | 2号 | x/y 角 15°,高度 4000km | 8层叠加线框,渐进透明度动画 | | **金字塔传感器** | 4号 | x/y 角 15°,高度 3000km | 单层线框 + 锥形填充 | 传感器覆盖区域使用红色半透明填充,直观展示瞬时对地覆盖范围。 #### 3. 星地链路与通信仿真 - **地面站**:郑州站 (113.65°E, 34.76°N),使用 **dish.gltf** 3D 雷达天线模型 - **星地链路**:地面站到每颗卫星的动态联通线,带流动箭头动画效果 - **距离触发机制**:每 50ms 计算星地距离,距离 < 2000km 时链路连通,传感器同时变为可见 - **监测区域**:红色半透明椭圆(半径 2000km),标识地面站监测覆盖范围 - **雷达扫描**:黄色旋转扇形波束动画,模拟雷达扫描效果 #### 4. 轨道特殊标记 - **近地点/远地点**:品红色/黄色圆点标记,带文字标签(卫星1号) - **星下点轨迹**:卫星在地表的投影轨迹线(卫星3号) - **卫星垂线**:从卫星到地表的虚线连接(卫星3号) - **地球经纬网格**:每 30° 间隔的经线和纬线,白色半透明 #### 5. 仿真时间控制 - Cesium 内置时间轴 + 动画控制器 - 默认 60 倍速播放,支持暂停/快进/回退 - **回到当前时刻**:一键重置仿真时间至真实当前时刻 - 3 天循环时长,支持长时间轨道推演 #### 6. 相机信息实时显示 画面底部状态栏实时输出观测视角参数: | 参数 | 说明 | | --------- | ------------------------------ | | 视点高度 | 相机离地距离(KM) | | 偏航角 | 水平旋转方位(°) | | 俯仰角 | 上下俯仰角度(°) | | 翻滚角 | 画面倾斜角度(°) | | 海拔高度 | 相机海拔(M) | | 经度/纬度 | 相机焦点地表坐标(度分秒格式) | --- ### 三、通信链路视窗 (`/link`) 专用于卫星与地面站之间通信链路的实时监控与链路预算分析,为星地通信系统设计提供可视化的链路质量评估。 #### 1. 实时链路监控 系统同时监控四颗卫星与郑州地面站的通信链路状态,每张卫星卡片实时展示: | 指标 | 说明 | | -------------- | ----------------------------------------------------- | | **星地斜距** | 卫星到地面站的直线距离,支持 km / 千km 自动单位切换 | | **传播延迟** | 电磁波信号从卫星到地面站的单向传播时间(ms) | | **天线指向角** | 地面站天线方位角(0-360°)与仰角,低仰角时黄色警告 | | **多普勒频移** | 卫星相对运动引起的频率偏移(kHz),靠近蓝色、远离红色 | | **通信窗口** | 当前连通剩余时长倒计时 / 下次进入窗口预估等待时间 | #### 2. 链路预算分析 基于标准链路预算模型,实时计算并展示星地通信链路的电性能参数: | 参数 | 说明 | | ---------------- | ------------------------------------------------ | | **自由空间损耗** | FSPL = 20log(d) + 20log(f) + 92.45(dB) | | **接收功率** | Pr = Pt + Gt + Gr - FSPL - Lother(dBm) | | **信噪比 SNR** | SNR = Pr - 10log(kTB) + 30(dB),带柱状图可视化 | 链路参数(载波频率、发射功率、噪声温度、带宽)支持面板内实时调整,所有计算即时响应。 #### 3. 3D 可视化 - 右侧 Cesium 视图聚焦郑州地面站,展示四颗卫星的轨道线与实时位置 - 通信链路连接线动态变色:**绿色 = 连通**,灰色 = 断开 - 黄色 2000km 监测圈标识地面站覆盖范围 - 协议栈图例(连通 / 断开 / 地面站) --- ### 四、坐标转换 (`/coordinate`) 基于 ootk v6 航天动力学库的全坐标系转换工具,覆盖 13 种坐标类型的任意双向互转。 #### 坐标系分类 | 类别 | 坐标系 | | ------------ | --------------------------------------------------------- | | **核心帧** | TEME、J2000 (ECI)、ITRF (ECEF) | | **轨道根数** | 经典开普勒六根数、春分点根数(无奇点) | | **相对坐标** | Hill (CW 方程)、RIC(径向/沿迹/法向) | | **观测坐标** | Geodetic (LLA)、RAE(斜距/方位/仰角)、地心 & 站心 RA/Dec | | **姿态表示** | 欧拉角 (Roll/Pitch/Yaw)、四元数 | #### 核心特性 - **J2000 万能中间帧**:所有坐标系先转到 J2000 再转到目标,避免 156 个转换函数 - **智能输入适配**:根据源坐标系自动切换输入字段(笛卡尔 / 六根数 / 球坐标等) - **欧拉角 ↔ 四元数互转**:基于方向余弦矩阵(DCM)的 trace 分支算法,处理万向节锁退化 - 历元时间选择器,支持验证不同时刻的转换结果 --- ### 五、空间态势感知 (`/ssa`) 对接 KeepTrack Space API v4,将真实卫星编目数据注入 Cesium 三维地球的全景式 SSA 平台。 #### 核心特性 - **卫星编目**:11 种数据源(全部活动卫星 / LEO / GEO / 碎片 / CelesTrak / SatNOGS 等),500+ 卫星实时 SGP4 传播渲染 - **轨道分类着色**:LEO 蓝 / GEO 绿 / 碎片灰 / 其他橙,数据源限定类型时统一着色 - **SOCRATES 交会预警**:碰撞概率四级分级(严重 ≥1e-3 / 高 / 中 / 低),5 分钟自动刷新 - **地面站过境预测**:6 个预设测控站点,金色标记与过境弧线可视化 - **三面板可折叠**:编目 / 过境 / 交会面板独立折叠,最大化 Cesium 视图 - **API 限流保护**:冷却计时 + 5 分钟缓存 + 401/403 自动提示重输 Key --- ### 六、发射预告 (`/launches`) 全球火箭发射计划的卡片式浏览与详情追踪,覆盖"发射前"信息维度。 #### 核心特性 - **卡片网格列表**:自适应 `auto-fill, minmax(340px, 1fr)` 布局,展示发射名称、时间、火箭、发射场 - **状态标记**:Go/Confirmed 绿色、Delayed/TBD 黄色、其余蓝色 - **发射详情页** (`/launches/:id`):发射任务图片、倒计时、任务信息、火箭参数、发射工位列表 - **火箭参数查询**:通过 `/v4/launch-vehicle/{name}` 获取火箭描述与关联工位 --- ### 七、空间指标 (`/metrics`) 空间目标数据驾驶舱,9 个 API 端点并行请求,展示宏观统计全景。 #### 核心特性 - **API 健康状态**:D1/R2 数据库状态、活跃/在轨/再入/总卫星数、平均 TLE 龄期 - **四大指标计数**:活跃载荷 / 非活跃载荷 / 总载荷 / 碎片数量,大号数字 + 彩色边框 - **TLE 龄期统计**:摘要(总数/均值/中位/最值)+ CSS 直方图 + 未来 TLE 警告 - **最近发射卫星**:近 30 天发射清单,NORAD ID / 名称 / 国际编号 / 国家 - **热门卫星排行**:近 7 天查询次数 TOP,反映社区关注焦点 - **API 用量统计**:总请求 / 独立 IP / 按端点 TOP / 按国家 TOP - **独立容错**:`Promise.allSettled` 确保任一接口失败不影响其他面板 --- ### 八、文本数据 (`/text-data`) 卫星编目数据的管道分隔 CSV 查询、浏览与一键导出。 #### 核心特性 - **三维筛选器**:对象类型(PAYLOAD/ROCKET_BODY/DEBRIS/UNKNOWN)× 国家代码 × 卫星名称 - **国家代码映射**:API 优先获取 + 本地 38 国兜底列表 - **管道分隔 CSV 解析**:按 `|` 分列、按 `\n` 分行,自动补齐尾部空列 - **等宽字体表格**:Consolas/Monaco 渲染,`show-overflow-tooltip` 超长截断 - **一键下载 CSV**:时间戳文件名,Blob 方式触发浏览器下载 - **性能保护**:`displayLimit = 500` 限制渲染行数 ## 项目结构 ``` tlePlus/ ├── public/ │ ├── gltf/ # 3D 模型资源 │ │ ├── weixing.gltf # 卫星模型 │ │ └── dish.gltf # 地面站雷达天线模型 │ └── textures/ # 地球纹理贴图 ├── src/ │ ├── components/ │ │ ├── ssa/ # 空间态势感知子组件 │ │ │ ├── ConjunctionPanel.vue # 交会预警面板 │ │ │ ├── GroundStationPanel.vue # 地面站与过境面板 │ │ │ ├── LaunchesPanel.vue # 发射面板(预留) │ │ │ ├── SatelliteCatalogPanel.vue # 卫星编目列表 │ │ │ └── SsaGlobe.vue # SSA Cesium 三维地球 │ │ ├── SatelliteTLEViewer.vue # TLE 数据平台(/tle) │ │ ├── SatelliteSimulation.vue # 卫星运行仿真(/simulation) │ │ ├── CommunicationLinkViewer.vue # 通信链路视窗(/link) │ │ └── CoordinateConverter.vue # 坐标转换工具(/coordinate) │ ├── views/ │ │ ├── SpaceSsa.vue # 空间态势感知主页面(/ssa) │ │ ├── LaunchMonitor.vue # 发射预告列表(/launches) │ │ ├── LaunchDetail.vue # 发射详情页(/launches/:id) │ │ ├── MetricsDashboard.vue # 空间指标面板(/metrics) │ │ └── TextDataViewer.vue # 文本数据查询(/text-data) │ ├── router/ │ │ └── index.ts # 路由配置(8 模块路由) │ ├── utils/ │ │ ├── keplerOrbit.ts # 开普勒轨道计算引擎 │ │ ├── tleCalculator.ts # TLE/SGP4 轨道传播 │ │ ├── tleParser.ts # TLE 数据解析器 │ │ ├── orbitPropagator.ts # 轨道传播与轨迹生成 │ │ ├── collisionCalculator.ts # 碰撞概率计算 │ │ ├── conjunctionAnalyzer.ts # 交会事件风险分级 │ │ ├── sensorGeometry.ts # 传感器几何计算 │ │ ├── linkBudget.ts # 链路预算计算工具 │ │ ├── coordinateConverter.ts # 全坐标系转换引擎(13种) │ │ └── keepTrackApi.ts # KeepTrack API v4 客户端 │ ├── types/ │ │ ├── tle.ts # TLE 数据类型定义 │ │ ├── collision.ts # 碰撞检测类型定义 │ │ └── ssa.ts # SSA 空间态势感知类型定义 │ ├── data/ │ │ └── sampleTle.ts # 内置 TLE 示例数据 │ ├── App.vue # 根组件(8 模块全局导航 + 路由视图) │ └── main.ts # 应用入口 ├── package.json ├── vite.config.ts └── tsconfig.json ``` ### 轨道计算引擎 - 基于开普勒轨道六要素(半长轴、偏心率、倾角、升交点赤经、近地点幅角、平近点角) - 牛顿迭代法求解开普勒方程 `M = E - e·sin(E)` - 支持高偏心率轨道(e > 0.8 使用改进初始猜测) - 3-1-3 旋转矩阵(Rz(Ω) · Rx(i) · Rz(ω))将轨道面坐标转换到 ECI - SGP4/SDP4 轨道传播(satellite.js),兼容 TLE 数据格式 ### 坐标转换系统 - 基于 ootk v6 航天动力学库,覆盖 TEME/J2000/ITRF 等 13 种坐标系 - J2000 万能中间帧架构,避免 (13 \times 12 = 156\) 个转换函数 - 欧拉角 ↔ 四元数 DCM 互转,trace 分支算法处理万向节锁退化 - 完整处理岁差、章动、GMST 旋转、极移等地球定向参数 ### 坐标系统 - 统一使用 **ECI(地心惯性坐标系)** 渲染卫星、轨道、传感器 - 地面站使用 ECF(地球固定坐标系)的经纬度定位 - 链路连线跨坐标系自动桥接 ### 3D 渲染 - 卫星/地面站使用 **glTF 2.0** 模型渲染 - 传感器线框使用 **CallbackProperty** 实现每帧实时更新 - 锥形填充使用 **CylinderGraphics**(topRadius=0)创建锥体 - 链路动画使用 **PolylineArrowMaterialProperty** 方向箭头 - 雷达扫描使用 **PolygonGraphics** + CallbackProperty 旋转多边形 - 动态 polyline 颜色使用 **ColorMaterialProperty** 包裹 CallbackProperty - 大规模实时渲染优化:500+ 卫星 SGP4 传播 + 轨道线邻点距离过滤 ### 通信链路预算 - 实现标准星地链路预算模型,包含自由空间损耗、接收功率、信噪比等参数 - ECI → ECF → ENU 坐标转换实现天线方位角/仰角精确计算 - 多普勒频移基于相对速度在视线方向的分量实时计算 - 通信窗口预计算法:根据轨道高度和地球半径计算可视时间占比 ### API 集成 - **KeepTrack Space API v4** 客户端封装,支持 20+ 端点 - API Key 管理与滚动再生,401/403 自动提示 - 5 分钟请求缓存 + 限流冷却保护(429 → 2 分钟冷却) - 编目数据 NORAD ID 自动从 TLE 行提取补全 - `Promise.allSettled` 并行请求 + 独立容错设计 ### 文本数据处理 - 管道分隔 CSV 解析(`|` 分列 + `\n` 分行 + 尾部空列补齐) - 动态 RESTful URL 路径构建(type/country/name 逐级追加) - 国家代码双层容错(API 优先 + 本地 38 国兜底) - Blob + URL.createObjectURL 实现客户端 CSV 一键下载 ## 结语 SpaceDt 是一个将航天领域专业知识与前端工程实践相结合的项目。从 TLE 解析到轨道预报,从 Cesium 场景搭建到性能优化,从 ootk 坐标转换到 KeepTrack API 集成,8 大功能模块覆盖了卫星轨道可视化与空间态势感知的核心场景。 如果你对卫星轨道力学、3D 可视化、空间态势感知或 Cesium 开发感兴趣,欢迎从这个项目入手,它既是一个实用的工具,也是一个不错的学习起点。 > **技术交流**:欢迎提出 Issue 或 PR 🚀
全栈项目部署实战指南:Java / Python / Vue / React 一站式搞定
# 全栈项目部署实战指南:Java / Python / Vue / React 一站式搞定 > 从本地开发到生产上线,覆盖 Spring Boot、FastAPI/Flask、Vue、React 四大技术栈的完整部署方案,结合 Docker 容器化 + Nginx 反向代理 + CI/CD 自动化,帮你打通项目上线的"最后一公里"。 > > **本文中的所有部署方式,均来自我日常工作中维护的多个生产项目,以及个人开源项目的实战经验。如果对你有帮助,希望给我的开源项目点赞支持一下吧,你们的点赞和收藏都是我的动力! https://github.com/DevYangJC** **🧪 实战推荐:** 如果你想找一个前后端分离的真实项目来练手部署,推荐试试我的开源项目 —— **[DataLoom](https://github.com/DevYangJC/DataLoom)**,把在线表格(类似 Excel)像零件一样装进你的项目里。它的架构和本文完全对应:**前端是独立 SPA(Vue 3 + Luckysheet)、后端是独立微服务(Spring Boot)、前后端之间只有 REST API 通信**。读完本文后,用它来实操一遍"前端 Nginx 托管 + 后端脚本部署 + Nginx 反向代理"的完整流程,体会会更深刻。 --- ## 目录 - [一、部署前置:服务器环境准备](#一部署前置服务器环境准备) - [1.1 服务器选型建议](#11-服务器选型建议) - [1.2 基础环境安装](#12-基础环境安装) - [1.3 部署前检查清单](#13-部署前检查清单) - [1.4 防火墙与安全组](#14-防火墙与安全组) - [二、Java(Spring Boot)项目部署](#二javaspring-boot项目部署) - [三、Python(FastAPI / Flask)项目部署](#三pythonfastapi--flask项目部署) - [四、前端(Vue / React)项目部署](#四前端vue--react项目部署) - [五、Nginx 反向代理与域名配置](#五nginx-反向代理与域名配置) - [六、Docker Compose 一键编排](#六docker-compose-一键编排) - [七、CI/CD 自动化部署](#七cicd-自动化部署) - [八、生产环境最佳实践](#八生产环境最佳实践) - [九、常见问题排查](#九常见问题排查) ---  ## 一、部署前置:服务器环境准备 ### 1.1 服务器选型建议 | 场景 | 推荐配置 | 适用项目 | |------|----------|----------| | 个人项目/测试 | 2C4G | 单体应用、静态前端 | | 中小型生产 | 4C8G | Spring Boot + 前端 | | 中大型生产 | 8C16G+ | 微服务、多容器编排 | > **云服务商选择**:国内推荐阿里云、腾讯云、华为云;海外推荐 AWS、DigitalOcean。学生和开发者可关注各厂商的优惠活动,通常首年价格极低。 ### 1.2 基础环境安装 在开始安装之前,先想清楚一个问题:**你部署的项目用到了哪些技术?** 不同的项目需要不同的运行环境,没必要一股脑全装上。下面这张表帮你快速判断: | 你要部署什么 | 必装环境 | 可选环境 | |-------------|---------|---------| | Spring Boot 项目 | JDK 17+ | Maven(服务器构建时需要) | | FastAPI / Flask 项目 | Python 3.10+、pip | Nginx(生产环境反向代理) | | Vue / React 前端项目 | Node.js 20+(构建时需要) | Nginx(托管静态文件) | | 使用 Docker 部署 | Docker、Docker Compose | — | | 需要数据库 | MySQL / PostgreSQL | Redis(缓存) | > **安装顺序建议**:先装基础工具(curl、git 等)→ 再装运行环境(JDK、Python、Node)→ 最后装基础设施(Docker、Nginx、数据库)。因为后面的软件可能依赖前面的工具来下载和安装。 以 CentOS 7/8 为例,下面按步骤安装: ```bash # ======================================== # 第一步:系统更新(装任何东西之前先更新) # ======================================== # CentOS 7 sudo yum update -y # CentOS 8 / Rocky Linux / AlmaLinux sudo dnf update -y # ======================================== # 第二步:基础工具(必须装,后面都会用到) # ======================================== sudo yum install -y curl wget git vim unzip net-tools lsof # curl - 下载文件用(装 Docker、Node 都靠它) # wget - 另一个下载工具,某些脚本只支持 wget # git - 拉代码用 # vim - 编辑配置文件 # unzip - 解压 zip 包 # net-tools - netstat 等网络排查工具 # lsof - 查看端口占用(服务启动失败时排查用) # ======================================== # 第三步:Docker 和 Docker Compose # ======================================== # 安装 Docker curl -fsSL https://get.docker.com | sh sudo systemctl start docker sudo systemctl enable docker # 把当前用户加入 docker 组,这样不用每次都 sudo sudo usermod -aG docker $USER # ⚠️ 执行上面这条后需要重新登录 SSH 才能生效 # 安装 Docker Compose sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" \ -o /usr/local/bin/docker-compose sudo chmod +x /usr/local/bin/docker-compose # 验证 docker --version docker-compose --version # ======================================== # 第四步:Nginx(前端托管 + 反向代理) # ======================================== # CentOS 必须先装 epel-release 源,不然找不到 nginx 包 sudo yum install -y epel-release sudo yum install -y nginx sudo systemctl start nginx sudo systemctl enable nginx # ======================================== # 第五步:JDK 17(Java 项目必装) # ======================================== sudo yum install -y java-17-openjdk java-17-openjdk-devel # 验证 java -version # javac -version # 验证开发工具包(服务器构建时需要) # 如果你的项目还在用 JDK 8 或 11,装对应版本: # sudo yum install -y java-11-openjdk # JDK 11 # sudo yum install -y java-1.8.0-openjdk # JDK 8 # 多版本 JDK 共存时,用 alternatives 切换默认版本: # sudo alternatives --config java # ======================================== # 第六步:Python 3(FastAPI / Flask 项目必装) # ======================================== # CentOS 7(系统自带 Python 2,需要额外装 Python 3) sudo yum install -y https://repo.ius.io/ius-release-el7.rpm sudo yum install -y python3u python3u-pip python3u-venv # CentOS 8+ 自带 Python 3 sudo dnf install -y python3 python3-pip # 验证 python3 --version pip3 --version # 设置 pip 国内镜像(加速下载,默认源在国外很慢) pip3 config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple # ======================================== # 第七步:Node.js 20+(前端构建时需要) # ======================================== # 注意:Node.js 只在"服务器上直接构建"时才需要 # 如果你在本地构建好 dist/ 再上传服务器,可以不装 curl -fsSL https://rpm.nodesource.com/setup_20.x | sudo bash - sudo yum install -y nodejs # 验证 node --version npm --version # 设置 npm 国内镜像(加速下载) npm config set registry https://registry.npmmirror.com # ======================================== # 第八步:Git(如果需要在服务器上拉代码) # ======================================== sudo yum install -y git # 配置 Git 用户信息(首次使用需要) git config --global user.name "你的名字" git config --global user.email "your@email.com" # 如果需要从私有仓库拉代码,配置 SSH 密钥: # ssh-keygen -t ed25519 -C "your@email.com" # 然后把 ~/.ssh/id_ed25519.pub 的内容添加到 Git 平台的 SSH Keys 设置中 ``` > **安装后验证清单**:装完跑一遍,确保每个工具都正常工作。如果某个命令报 `command not found`,说明没装上或者环境变量没配好: > > ```bash > java -version # ✅ 应该输出 openjdk version "17.x.x" > python3 --version # ✅ 应该输出 Python 3.x.x > node --version # ✅ 应该输出 v20.x.x > docker --version # ✅ 应该输出 Docker 2x.x.x > nginx -v # ✅ 应该输出 nginx version: nginx/1.x.x > git --version # ✅ 应该输出 git version 2.x.x > ``` ### 1.3 部署前检查清单  很多新手拿到一台服务器就急着装软件、传代码、启动服务,结果到处报错——端口被占了、数据库连不上、文件权限不对、配置文件少写了逗号……这些问题如果在部署前花 10 分钟检查一遍,能省下好几个小时的排错时间。 部署前的检查就像出门旅行前的"伸手要钱"(身份证、手机、钥匙、钱包)——看似啰嗦,但少一样都走不了。 ```mermaid graph TD A["📋 部署前检查"] --> B["1️⃣ 代码与版本"] A --> C["2️⃣ 配置与依赖"] A --> D["3️⃣ 权限与密钥"] A --> E["4️⃣ 网络与端口"] A --> F["5️⃣ 磁盘与资源"] B --> G["✅ 全部通过 → 开始部署"] C --> G D --> G E --> G F --> G B -.->|"❌ 不通过"| H["🔧 修复后重新检查"] C -.->|"❌ 不通过"| H D -.->|"❌ 不通过"| H E -.->|"❌ 不通过"| H F -.->|"❌ 不通过"| H H --> A ``` #### 1️⃣ 代码与版本检查 | 检查项 | 怎么检查 | 为什么重要 | |--------|---------|-----------| | 代码是否已提交并推送到远程 | `git status` 看有没有未提交的改动 | 你部署的应该是远程仓库的代码,而不是本地改了一半的版本 | | 部署的版本号是否正确 | `git tag` 或 `git log --oneline -5` 确认当前版本 | 避免部署了错误的分支或旧的提交 | | 分支是否正确 | `git branch` 确认是 release/main 分支 | 部署了开发分支的代码上生产,后果你懂的 | | JAR 包 / 构建产物是否最新 | 对比构建时间和代码提交时间 | 避免用了缓存的旧包,新代码没打进去 | ```bash # 在本地或 CI 环境中执行 git status # 确认没有未提交的改动 git log --oneline -5 # 确认最新提交是你期望的版本 git tag -l "v*" # 查看所有版本标签 # 打一个版本标签(发布时建议打 tag) git tag -a v1.2.0 -m "发布 v1.2.0" git push origin v1.2.0 ``` #### 2️⃣ 配置与依赖检查 | 检查项 | 怎么检查 | 为什么重要 | |--------|---------|-----------| | 配置文件是否齐全 | 确认 `application-prod.yml` / `.env` 等文件存在 | 缺了配置文件,服务启动后连不上数据库、读不到参数 | | 数据库连接信息是否正确 | 检查 host、port、用户名、数据库名 | 配置写错了或者指向了测试库,生产数据就乱套了 | | 第三方服务密钥是否配置 | 检查短信、支付、OSS 等的 API Key | 缺了密钥,相关功能全部不可用 | | 依赖版本是否匹配 | 对比开发环境和生产环境的 JDK/Python/Node 版本 | "在我电脑上能跑"最常见的原因就是版本不一致 | | 环境变量是否设置 | 检查 `.env` 文件或 `export` 的变量 | 密码、密钥等敏感配置通常通过环境变量注入 | ```bash # 检查配置文件是否存在且内容完整 ls -la /opt/app/user-service/config/ cat /opt/app/user-service/config/application-prod.yml # 检查关键配置项 grep -E "datasource|redis|port" /opt/app/user-service/config/application-prod.yml # 测试数据库是否可连接(在服务器上执行) mysql -h 10.0.0.100 -u appuser -p -e "SELECT 1" # MySQL # python3 -c "import redis; r=redis.Redis(host='10.0.0.100',port=6379,password='xxx'); print(r.ping())" # Redis # 检查 Python 依赖是否安装 pip3 list | grep -E "fastapi|flask|gunicorn|uvicorn" # 检查 Node.js 依赖是否安装(前端项目) ls node_modules/ | head # 确认依赖已安装 ``` #### 3️⃣ 权限与密钥检查 | 检查项 | 怎么检查 | 为什么重要 | |--------|---------|-----------| | SSH 密钥是否配置 | `ssh -T git@github.com` 测试连通性 | 没有密钥就没法从私有仓库拉代码 | | 文件目录权限是否正确 | `ls -la /opt/app/` 查看权限 | 权限不对会导致应用无法读取配置或写入日志 | | 应用是否以非 root 用户运行 | 检查启动脚本中的用户身份 | root 运行应用一旦被攻破,整个服务器都沦陷 | | SSL 证书是否有效 | `openssl x509 -enddate` 检查过期时间 | 证书过期用户访问会提示"不安全",直接流失用户 | | 数据库用户权限是否最小化 | 检查应用用的数据库账号是否只有必要的权限 | 给应用 root 权限 = 给小偷配了万能钥匙 | ```bash # ---- SSH 密钥检查 ---- # 测试 GitHub SSH 连通性 ssh -T git@github.com # ✅ 看到 "Hi xxx! You've successfully authenticated" 说明密钥配置正确 # 生成新的 SSH 密钥(如果没有的话) ssh-keygen -t ed25519 -C "deploy@myapp" # 把公钥添加到 Git 平台:cat ~/.ssh/id_ed25519.pub 复制内容 # ---- 文件权限检查 ---- # 应用目录权限:所有者可读写执行,组和其他用户只读 ls -la /opt/app/user-service/ # ✅ 正确:drwxr-xr-x appuser appgroup ... # ❌ 危险:drwxrwxrwx (777,任何人都能改) # 修正权限 chown -R appuser:appgroup /opt/app/user-service/ chmod 755 /opt/app/user-service/bin/ chmod 600 /opt/app/user-service/config/application-prod.yml # 配置文件只有所有者可读写 # ---- SSL 证书有效期检查 ---- # Let's Encrypt 证书有效期 90 天,务必设置自动续期 openssl x509 -enddate -noout -in /etc/letsencrypt/live/example.com/fullchain.pem # 检查自动续期定时任务 sudo crontab -l | grep certbot # 如果没有,添加一个: # echo "0 3 * * * certbot renew --quiet" | sudo crontab - ``` #### 4️⃣ 网络与端口检查 | 检查项 | 怎么检查 | 为什么重要 | |--------|---------|-----------| | 应用端口是否被占用 | `lsof -i :8080` 或 `netstat -tlnp \| grep 8080` | 端口被别的程序占了,你的服务启动就报 "Address already in use" | | 服务器能否访问外部网络 | `curl -I https://www.baidu.com` | 服务器出不了外网,npm install / pip install 就会失败 | | 数据库服务器是否可达 | `telnet 10.0.0.100 3306` 或 `nc -zv 10.0.0.100 3306` | 连不上数据库,应用启动就报错 | | 防火墙是否放通了必要端口 | `sudo firewall-cmd --list-ports` | 端口没开,外部请求根本进不来 | | DNS 解析是否正常 | `nslookup your-domain.com` 或 `dig your-domain.com` | 域名没解析到服务器 IP,用户访问不了 | ```bash # 检查端口占用(部署前务必检查!) lsof -i :8080 # 检查 8080 端口 lsof -i :80 # 检查 80 端口 netstat -tlnp | grep -E "8080|80|443|3306" # 如果端口被占用,找到并停掉占用的进程 kill -15 <PID> # 先礼貌退出 # kill -9 <PID> # 实在不停再强制杀 # 测试服务器网络连通性 curl -I https://www.baidu.com # 测试外网 ping 10.0.0.100 # 测试内网数据库服务器 nc -zv 10.0.0.100 3306 # 测试数据库端口是否可连 nc -zv 10.0.0.100 6379 # 测试 Redis 端口是否可连 # DNS 解析检查 nslookup your-domain.com # 如果域名还没解析,可以先在本地 hosts 文件中临时配置: # echo "你的服务器IP your-domain.com" | sudo tee -a /etc/hosts ``` #### 5️⃣ 磁盘与资源检查 | 检查项 | 怎么检查 | 为什么重要 | |--------|---------|-----------| | 磁盘剩余空间 | `df -h` | 磁盘满了日志写不进去、数据库崩溃,问题一个比一个大 | | 内存是否充足 | `free -h` | 内存不够 Java 直接 OOM 崩掉 | | CPU 负载是否正常 | `top -bn1 \| head -5` | CPU 已经跑满了再加服务,新服务也起不来 | | 日志清理策略是否设置 | `du -sh /var/log/` 检查日志大小 | 日志无限增长是磁盘爆满的头号原因 | ```bash # 磁盘空间(使用率超过 80% 就要警惕了) df -h # Filesystem Size Used Avail Use% Mounted on # /dev/vda1 40G 15G 23G 40% / ← ✅ 正常 # /dev/vda1 40G 35G 3G 93% / ← ⚠️ 危险!赶紧清理 # 内存(关注 available 列,这是实际可用内存) free -h # total used free available # Mem: 7.6G 3.2G 1.1G 4.0G ← ✅ 充裕 # Mem: 7.6G 6.8G 128M 500M ← ⚠️ 偏紧,考虑加内存或减少 JVM 分配 # CPU 负载(load average 三个数分别代表 1/5/15 分钟的平均负载) # 数值超过 CPU 核心数说明过载了 top -bn1 | head -5 # 大文件排查(找出占空间最多的目录) du -sh /* | sort -rh | head -10 ``` > **一键检查脚本**:如果你觉得上面逐项检查太麻烦,可以把所有检查项写成一个脚本,部署前跑一次就行。把下面的脚本保存为 `pre-deploy-check.sh`,每次部署前执行 `bash pre-deploy-check.sh`: > > ```bash > #!/bin/bash > # 部署前一键检查脚本 > # 用法: bash pre-deploy-check.sh > > PASS=0; FAIL=0 > check() { > local desc="$1" cmd="$2" > echo -n "[$(echo $desc | cut -c1-20)] ... " > if eval "$cmd" &>/dev/null; then > echo "✅ 通过"; ((PASS++)) > else > echo "❌ 未通过"; ((FAIL++)) > fi > } > > echo "========== 部署前检查 ==========" > echo "" > echo "--- 环境 ---" > check "Java 安装" "java -version" > check "Python 安装" "python3 --version" > check "Node.js 安装" "node --version" > check "Docker 安装" "docker --version" > check "Nginx 安装" "nginx -v" > check "Git 安装" "git --version" > > echo "" > echo "--- 网络 ---" > check "外网连通" "curl -sI https://www.baidu.com" > check "DNS 解析" "nslookup baidu.com" > > echo "" > echo "--- 资源 ---" > check "磁盘 > 20%" "test $(df / | tail -1 | awk '{print $5}' | tr -d '%') -lt 80" > check "内存 > 500M" "test $(free -m | awk '/Mem/{print $7}') -gt 500" > > echo "" > echo "========== 结果: ✅ ${PASS} 通过, ❌ ${FAIL} 未通过 ==========" > if [ $FAIL -gt 0 ]; then > echo "⚠️ 请先修复未通过的项目再进行部署!" > exit 1 > fi > ``` ### 1.4 防火墙与安全组 ```bash # CentOS 7/8 使用 firewalld sudo systemctl start firewalld sudo systemctl enable firewalld # 开放常用端口 sudo firewall-cmd --permanent --add-port=22/tcp # SSH sudo firewall-cmd --permanent --add-port=80/tcp # HTTP sudo firewall-cmd --permanent --add-port=443/tcp # HTTPS sudo firewall-cmd --permanent --add-port=8080/tcp # Spring Boot 默认端口 # sudo firewall-cmd --permanent --add-port=3306/tcp # MySQL(仅内网访问,不建议公网开放) # 重载防火墙使规则生效 sudo firewall-cmd --reload # 查看已开放端口 sudo firewall-cmd --list-ports ``` > **安全提醒**:数据库端口(3306、5432、6379 等)务必不要直接暴露在公网,应通过安全组限制为内网访问。 --- ## 二、Java(Spring Boot)项目部署  ### 2.1 项目打包 Spring Boot 项目内嵌 Tomcat,直接打成可执行 JAR 即可: ```bash # Maven 项目 mvn clean package -DskipTests # Gradle 项目 gradle clean build -x test ``` 构建产物在 `target/` 目录下,类似 `user-service-1.0.0.jar`。 ### 2.2 方式一:脚本化部署(推荐,多服务运维首选) #### 为什么要用脚本部署? 你可能想问:Java 项目直接一条 `java -jar xxx.jar` 不就能跑了吗?确实能跑,但问题来了——你的服务器重启了怎么办?服务挂了怎么办?你要更新 JAR 包怎么办?每次都手动敲命令,时间长了自己都忘了怎么操作,换个人来接手更是两眼一抹黑。 脚本化部署就是把日常操作"录"下来,变成一条命令就能搞定的事情。就像微波炉的"一键热牛奶"按钮,你不需要知道微波炉内部怎么运作,按一下就行。同样的道理: - **服务没启动**,运行脚本 → 自动启动 - **服务已经在跑**,运行同一个脚本 → 自动重启(先停再启) - **要更新版本**,运行备份脚本 → 旧版本安全保存,出问题随时回退 这种方式最大的好处就是**简单可靠**——不需要学 Docker、不需要懂容器网络、不需要额外的运维工具,一台装了 Java 的 Linux 服务器就能跑。对于大多数中小项目来说,这就够了。 ```mermaid graph TD A["运行 ./restart.sh"] --> B{"服务是否在运行?"} B -->|没在跑| C["启动服务<br/>nohup java -jar xxx.jar &"] B -->|已经在跑| D["先停掉旧进程<br/>kill 旧PID"] D --> E["等待进程完全退出"] E --> C C --> F["记录新PID<br/>写入 pid 文件"] F --> G["✅ 服务已就绪"] ``` #### 目录结构设计 在开始之前,先说清楚文件放在哪里。一个好的目录结构就像一个整理好的工具箱——扳手在哪、螺丝刀在哪,一目了然,不用每次都翻箱倒柜。 我们规定所有服务都放在 `/opt/app/` 下面,每个服务一个独立的文件夹,里面的子目录各有各的用处: ``` /opt/app/ ├── user-service/ # 用户服务 │ ├── app/ │ │ └── user-service.jar # JAR 包(应用本体) │ ├── config/ │ │ └── application-prod.yml # 外部配置文件 │ ├── logs/ # 运行日志 │ ├── tmp/ # 临时文件(存放 PID 文件等) │ └── bin/ │ ├── restart.sh # 重启/启动脚本(核心,就这一个) │ └── backup.sh # 备份脚本 │ ├── api-gateway/ # API 网关 │ ├── app/ │ │ └── api-gateway.jar │ ├── config/ │ │ └── application-prod.yml │ ├── logs/ │ ├── tmp/ │ └── bin/ │ ├── restart.sh │ └── backup.sh │ └── backup/ # 发布备份(回滚用) ├── user-service/ └── api-gateway/ ``` 这些目录都是什么用?用大白话给你解释一下: | 目录 | 里面放什么 | 为什么要单独放 | |------|-----------|---------------| | `app/` | JAR 包,你的应用本体 | 和配置文件分开,更新时只需替换这里面的 JAR 包,配置不受影响 | | `config/` | `application-prod.yml` 外部配置 | 改数据库密码、调端口号,直接编辑这个文件就行,不用重新打包 | | `logs/` | 运行产生的日志文件 | 日志会越来越大,单独放方便定期清理,不会撑爆磁盘 | | `tmp/` | PID 文件(记录进程号) | 脚本靠这个文件判断服务是否在运行,就像门上挂的"有人/无人"牌子 | | `bin/` | 管理脚本 | 所有运维操作都在这里,一条命令搞定 | | `backup/` | 旧版本 JAR 包备份 | 更新前自动备份,万一新版本有问题,把旧的换回来就行 | #### 一键初始化目录结构 每新增一个服务都要手动建这么多文件夹?当然不用。下面这个初始化脚本帮你一键创建好所有目录,新项目部署时跑一次就行: ```bash #!/bin/bash #=================================================== # 初始化服务目录结构 # 用法: ./init-structure.sh <服务名> # 示例: ./init-structure.sh user-service #=================================================== APP_HOME="/opt/app" SVC_NAME=$1 # 检查参数 if [ -z "$SVC_NAME" ]; then echo "❌ 请提供服务名称!" echo "用法: $0 <服务名>" echo "示例: $0 user-service" exit 1 fi SVC_DIR="$APP_HOME/$SVC_NAME" # 检查是否已存在 if [ -d "$SVC_DIR" ]; then echo "⚠️ 目录已存在: $SVC_DIR" read -p "是否继续?(会跳过已存在的目录)[y/N] " confirm if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then echo "已取消" exit 0 fi fi echo "🔧 正在初始化服务目录: $SVC_DIR" # 创建各子目录 mkdir -p "$SVC_DIR/app" mkdir -p "$SVC_DIR/config" mkdir -p "$SVC_DIR/logs" mkdir -p "$SVC_DIR/tmp" mkdir -p "$SVC_DIR/bin" mkdir -p "$APP_HOME/backup/$SVC_NAME" # 创建重启脚本 cat > "$SVC_DIR/bin/restart.sh" << 'SCRIPT' #!/bin/bash #=================================================== # 服务重启/启动脚本 # 逻辑:服务没启动 → 启动;已经在跑 → 重启 # 用法: ./restart.sh [prod|dev] #=================================================== # ---- 基础配置(按实际项目修改这三行)---- APP_NAME="这里改成你的服务名" JAR_NAME="这里改成你的jar包名.jar" ACTIVE_PROFILE=${1:-prod} # ---- 以下不用改 ---- APP_DIR=$(cd "$(dirname "$0")/.." && pwd) JAR_PATH="$APP_DIR/app/$JAR_NAME" CONFIG_DIR="$APP_DIR/config" LOG_DIR="$APP_DIR/logs" PID_FILE="$APP_DIR/tmp/app.pid" # JVM 参数 JVM_OPTS="-Xms512m -Xmx1024m" JVM_OPTS="$JVM_OPTS -XX:+UseG1GC" JVM_OPTS="$JVM_OPTS -XX:+HeapDumpOnOutOfMemoryError" JVM_OPTS="$JVM_OPTS -XX:HeapDumpPath=$LOG_DIR/heapdump.hprof" JVM_OPTS="$JVM_OPTS -Djava.security.egd=file:/dev/./urandom" JVM_OPTS="$JVM_OPTS -Dfile.encoding=UTF-8" # 颜色输出 RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m' info() { echo -e "${GREEN}[INFO]${NC} $1"; } warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } error() { echo -e "${RED}[ERROR]${NC} $1"; } # 获取 PID get_pid() { if [ -f "$PID_FILE" ]; then cat "$PID_FILE" 2>/dev/null fi } # 检查是否在运行 is_running() { local pid=$(get_pid) [ -z "$pid" ] && return 1 if ps -p "$pid" > /dev/null 2>&1; then return 0 else rm -f "$PID_FILE" return 1 fi } # 停止服务 do_stop() { if ! is_running; then warn "$APP_NAME 未在运行" return 0 fi local pid=$(get_pid) info "正在停止 $APP_NAME (PID: $pid) ..." # 先发 SIGTERM,让应用优雅关闭(保存数据、释放连接) kill -15 "$pid" # 等最多 30 秒 for i in $(seq 1 30); do if ! ps -p "$pid" > /dev/null 2>&1; then info "$APP_NAME 已优雅停止" rm -f "$PID_FILE" return 0 fi sleep 1 done # 超时了还没停,强制杀掉 warn "优雅关闭超时,强制终止 ..." kill -9 "$pid" rm -f "$PID_FILE" info "$APP_NAME 已强制停止" } # 启动服务 do_start() { if is_running; then warn "$APP_NAME 已在运行 (PID: $(get_pid)),将重启 ..." do_stop sleep 2 fi if [ ! -f "$JAR_PATH" ]; then error "找不到 JAR 文件: $JAR_PATH" exit 1 fi mkdir -p "$LOG_DIR" "$APP_DIR/tmp" info "正在启动 $APP_NAME ..." info " 环境 : $ACTIVE_PROFILE" info " JAR : $JAR_PATH" info " 配置 : $CONFIG_DIR/" nohup java $JVM_OPTS \ -jar "$JAR_PATH" \ --spring.profiles.active="$ACTIVE_PROFILE" \ --spring.config.location="$CONFIG_DIR/" \ >> "$LOG_DIR/console-$(date '+%Y%m%d%H%M').log" 2>&1 & echo $! > "$PID_FILE" sleep 5 if is_running; then info "✅ $APP_NAME 启动成功 (PID: $(get_pid))" else error "❌ $APP_NAME 启动失败!请查看日志: $LOG_DIR/" exit 1 fi } # 主逻辑:运行就是重启/启动 do_start SCRIPT # 创建备份脚本 cat > "$SVC_DIR/bin/backup.sh" << 'SCRIPT' #!/bin/bash #=================================================== # 服务备份脚本 # 用法: ./backup.sh # 功能: 把当前 JAR 包复制到 backup 目录,带时间戳 #=================================================== APP_NAME="这里改成你的服务名" JAR_NAME="这里改成你的jar包名.jar" APP_DIR=$(cd "$(dirname "$0")/.." && pwd) JAR_PATH="$APP_DIR/app/$JAR_NAME" BACKUP_DIR="/opt/app/backup/$APP_NAME" RED='\033[0;31m'; GREEN='\033[0;32m'; NC='\033[0m' info() { echo -e "${GREEN}[INFO]${NC} $1"; } error() { echo -e "${RED}[ERROR]${NC} $1"; } if [ ! -f "$JAR_PATH" ]; then error "找不到 JAR 文件: $JAR_PATH" exit 1 fi mkdir -p "$BACKUP_DIR" # 备份文件名加上时间戳,方便识别 BACKUP_NAME="${JAR_NAME}.bak_$(date '+%Y%m%d%H%M%S')" cp "$JAR_PATH" "$BACKUP_DIR/$BACKUP_NAME" info "✅ 备份完成: $BACKUP_DIR/$BACKUP_NAME" # 只保留最近 5 个备份,旧的自动清理 ls -t "$BACKUP_DIR"/*.bak_* 2>/dev/null | tail -n +6 | xargs -r rm -f info "已清理旧备份(保留最近 5 个)" SCRIPT # 设置执行权限 chmod +x "$SVC_DIR/bin/restart.sh" chmod +x "$SVC_DIR/bin/backup.sh" echo "" echo "✅ 目录结构初始化完成!" echo "" echo "📁 $SVC_DIR/" echo " ├── app/ ← 把 JAR 包放这里" echo " ├── config/ ← 把 application-prod.yml 放这里" echo " ├── logs/ ← 日志会自动写到这里" echo " ├── tmp/ ← PID 文件自动管理" echo " └── bin/" echo " ├── restart.sh ← 启动/重启脚本" echo " └── backup.sh ← 备份脚本" echo "" echo "⚡ 下一步:" echo " 1. 把 JAR 包上传到 $SVC_DIR/app/" echo " 2. 把配置文件放到 $SVC_DIR/config/" echo " 3. 修改 restart.sh 和 backup.sh 顶部的 APP_NAME 和 JAR_NAME" echo " 4. 运行 $SVC_DIR/bin/restart.sh 启动服务" ``` 使用方式非常简单: ```bash # 初始化一个新服务的目录结构 ./init-structure.sh user-service # 再初始化另一个服务 ./init-structure.sh api-gateway ``` #### 核心脚本详解 初始化完成后,每个服务的 `bin/` 目录下会有两个脚本。这两个脚本就是日常运维的全部武器,不需要记复杂的命令,会这两个就够了。 **脚本一:restart.sh —— 启动/重启服务** 这是最核心的脚本,日常 90% 的操作都用它。它的工作逻辑很简单:**服务没启动就启动,已经在跑就先停再启**。你不需要关心服务当前是什么状态,运行就完了。 ```mermaid graph TD A["运行 ./restart.sh"] --> B{"检查 PID 文件<br/>服务是否在运行?"} B -->|"没有 PID 文件<br/>或进程已死"| C["直接启动"] B -->|"有 PID 且进程活着"| D["kill -15 发送停止信号<br/>给应用 30 秒优雅关闭"] D --> E{"30 秒内停了吗?"} E -->|是| F["确认已停"] E -->|否| G["kill -9 强制杀掉<br/>防止无限卡住"] G --> F F --> H["等待 2 秒<br/>确保端口释放"] H --> C C --> I["nohup java -jar xxx.jar &<br/>后台启动服务"] I --> J["记录 PID 到 tmp/app.pid"] J --> K["等待 5 秒检查<br/>确认启动成功"] K --> L["✅ 服务已就绪"] ``` 几个你可能好奇的设计细节,用大白话解释一下: **为什么要 PID 文件?** PID 文件就是记录"当前运行的进程号"的小文件,放在 `tmp/app.pid` 里。脚本靠它判断服务是否在运行——有 PID 文件且对应进程还活着,说明服务在跑;没有 PID 文件或进程已经死了,说明服务停了。这比每次都 `ps -ef | grep xxx` 去查要快得多、准得多。 **为什么先 kill -15 再 kill -9?** `kill -15`(SIGTERM)相当于"礼貌地请应用退出",Spring Boot 收到这个信号后会执行清理工作——关闭数据库连接、保存缓存、写完日志——然后优雅退出。`kill -9`(SIGKILL)相当于"直接拔电源",进程立刻消失,什么清理都来不及做。所以我们的策略是:先礼貌请退,等 30 秒,如果还不走再强制拖走。 **为什么用 nohup?** `nohup` 的意思是"不要因为终端关闭就停掉程序"。如果没有 nohup,你 SSH 登录服务器启动 Java 进程,关掉 SSH 窗口后进程就跟着死了。加了 nohup,即使你关掉终端、断开 SSH,服务依然在后台运行。 **为什么 sleep 5 秒再检查?** Java 应用启动需要时间——加载类、初始化 Spring 容器、连接数据库……这些不是瞬间完成的。等 5 秒再检查进程是否还活着,可以过滤掉"刚启动就崩了"的情况。如果 5 秒后进程还活着,基本可以认为启动成功了。 **脚本二:backup.sh —— 备份当前版本** 更新版本之前先备份,这是运维的基本素养。万一新版本有 bug,把备份的旧 JAR 包换回来就能恢复,比重新构建快得多。 ```mermaid graph LR A["运行 ./backup.sh"] --> B["把当前 JAR 复制到<br/>/opt/app/backup/服务名/"] B --> C["文件名加上时间戳<br/>user-service.jar.bak_202606161030"] C --> D["检查备份数量"] D --> E{"超过 5 个?"} E -->|是| F["自动删除最老的<br/>只保留最近 5 个"] E -->|否| G["✅ 备份完成"] F --> G ``` 备份文件名带时间戳,这样你能清楚地知道每个备份是什么时候做的。自动保留最近 5 个备份,太老的自动清理,防止备份目录无限膨胀。 #### 日常运维——就这两条命令 | 场景 | 命令 | 效果 | |------|------|------| | 首次启动服务 | `./restart.sh` | 检测到没在运行,直接启动 | | 重启服务 | `./restart.sh` | 检测到在运行,先停再启 | | 切换到开发环境 | `./restart.sh dev` | 以 dev 环境启动(默认是 prod) | | 更新版本前备份 | `./backup.sh` | 当前 JAR 包安全保存到 backup 目录 | 就这些,没有复杂的参数,没有多余的子命令。 #### 发布更新完整流程 下面这张图展示了从"开发完成"到"上线运行"的完整操作流程,每一步都配有对应的命令: ```mermaid graph TD A["1️⃣ 备份当前版本"] --> B["2️⃣ 停止并重启服务<br/>(restart.sh 会自动停旧的启新的)"] B --> C["3️⃣ 上传新 JAR 包<br/>覆盖 app/ 下的旧文件"] C --> D["4️⃣ 再次运行 restart.sh<br/>用新 JAR 启动服务"] D --> E{"5️⃣ 检查日志<br/>服务是否正常?"} E -->|"正常"| F["✅ 发布完成"] E -->|"异常"| G["6️⃣ 从 backup 恢复旧版本<br/>cp backup/xxx.jar app/"] G --> H["再次 restart.sh"] H --> F ``` 对应的命令操作: ```bash # 1. 先备份(防止翻车) cd /opt/app/user-service/bin ./backup.sh # 2. 上传新 JAR 包到 app 目录(从你本地电脑上传到服务器) scp target/user-service-1.0.1.jar user@server:/opt/app/user-service/app/user-service.jar # 3. 运行 restart.sh,自动停旧版本、启新版本 ./restart.sh # 4. 看一眼日志,确认启动正常 tail -f ../logs/console-*.log # 5. 万一新版本有问题,从备份恢复 cp /opt/app/backup/user-service/user-service.jar.bak_20260616103000 \ /opt/app/user-service/app/user-service.jar ./restart.sh ``` #### 外部配置文件说明 你可能注意到,启动命令里有一行 `--spring.config.location=../config/`。这是什么意思? Spring Boot 项目打包时,配置文件(`application.yml`)会被打进 JAR 包里。但生产环境的数据库地址、密码跟开发环境肯定不一样,你不可能为每台服务器单独打一个 JAR 包。`--spring.config.location` 就是告诉 Spring Boot:"别用 JAR 包里面的配置了,用我指定目录下的配置文件。" 这样 JAR 包只打一次,不同服务器放不同的 `application-prod.yml` 就行。 ```yaml # /opt/app/user-service/config/application-prod.yml server: port: 8081 spring: datasource: url: jdbc:mysql://10.0.0.100:3306/user_db?useSSL=true username: ${DB_USER} # 也可通过环境变量注入,避免密码明文写在文件里 password: ${DB_PASSWORD} hikari: maximum-pool-size: 20 logging: file: name: logs/application.log logback: rollingpolicy: max-file-size: 50MB max-history: 30 ``` 这种方式的好处用三句话总结: 1. **改配置不需要重新打包** — 直接编辑 yml 文件,运行 `./restart.sh` 就生效 2. **不同服务器可以有不同配置** — JAR 包保持一致,配置各管各的 3. **敏感信息不入代码仓库** — 数据库密码、API 密钥只存在于服务器上,不会泄露到 Git ### 2.3 方式二:systemd 服务管理(适合需要开机自启的场景) 如果你的服务需要**开机自启**或与系统服务统一管理,可以将脚本注册为 systemd 服务: ```ini # /etc/systemd/system/user-service.service [Unit] Description=User Service (Spring Boot) After=network.target [Service] Type=forking User=deploy WorkingDirectory=/opt/app/user-service ExecStart=/opt/app/user-service/bin/app.sh start ExecStop=/opt/app/user-service/bin/app.sh stop ExecReload=/opt/app/user-service/bin/app.sh restart PIDFile=/opt/app/user-service/tmp/app.pid Restart=on-failure RestartSec=10 [Install] WantedBy=multi-user.target ``` ```bash # 注册并启动 sudo systemctl daemon-reload sudo systemctl start user-service sudo systemctl enable user-service # 开机自启 # 查看状态和日志 sudo systemctl status user-service sudo journalctl -u user-service -f ``` > **提示**:使用 `Type=forking` 配合脚本中的后台启动(nohup &),systemd 能通过 PIDFile 正确追踪进程状态。 ### 2.4 方式三:Docker 容器化部署 #### 什么是 Docker?为什么要用 Docker? 先说一个很多人都会遇到的痛点:**"在我电脑上明明能跑啊!"**——你本地开发得好好的,一部署到服务器就各种报错,JDK 版本不对、系统依赖缺失、文件路径写死……这些问题说到底都是"环境不一致"造成的。 Docker 就是来解决这个问题的。它的核心思路很简单:**把你的应用和它需要的所有依赖(JDK、系统库、配置文件)一起打包成一个"镜像"**,这个镜像就像一个密封的集装箱——不管你把它搬到哪台机器上,只要装了 Docker,打开就能跑,环境一模一样,不会再有"在我电脑上能跑"的尴尬。 用大白话来打几个比方: - **镜像(Image)** = 装好软件的系统盘。比如 `mysql:8.0` 这个镜像,就是一个已经装好了 MySQL 8.0 的"系统盘",你拿过来直接用就行,不用自己安装配置。 - **容器(Container)** = 用系统盘装好的、正在运行的电脑。一个镜像可以"装"出多个容器,就像一张系统盘可以装多台电脑。 - **Dockerfile** = 安装说明书。告诉 Docker 怎么一步步打包你的应用——先装 JDK,再复制代码,再编译打包……每一步都写清楚。 - **Docker Compose** = 批量启动器。你的项目通常不只是一个应用,还有数据库、缓存、Nginx……Docker Compose 让你用一个 YAML 文件定义所有服务,一条命令全部拉起来。 ```mermaid graph LR A["Dockerfile<br/>安装说明书"] -->|docker build| B["镜像 Image<br/>打包好的系统盘"] B -->|docker run| C["容器 Container<br/>正在运行的服务"] B -->|docker run| D["容器 Container<br/>另一个实例"] B -->|docker run| E["容器 Container<br/>又一个实例"] F["docker-compose.yml<br/>批量编排文件"] -->|docker-compose up| G["一键启动<br/>所有服务"] ``` > **Docker vs 传统部署的直观对比**:传统部署就像自己买砖、买水泥、雇工人盖房子,每换一块地就得重新来;Docker 部署就像买了一个移动板房,里面水电齐全,搬到哪里都能直接住。 本节将分两部分讲解:第一部分是**测试环境单节点部署**(入门友好,快速上手),第二部分是**生产级多节点集群部署**(正式上线用,高可用保障)。 --- #### 第一部分:测试环境单节点部署 ##### 什么是单节点部署? 单节点部署就是把所有服务——应用、数据库、缓存、Nginx——都装在同一台服务器上,各自跑在独立的 Docker 容器里。就像把厨房、卧室、客厅都放在一个房间,虽然挤了点,但一个人住完全够用。 **适合场景**:自己开发测试、功能验证、给客户做演示。 **不适合场景**:正式对外提供服务。因为一旦这台机器宕机(断电、硬件故障、网络中断),所有服务全挂,用户完全无法访问。生产环境必须用多节点,后面会讲。 ##### 部署架构 下面这张图展示了单节点部署时,各容器之间的关系和调用链路。用户的请求先到 Nginx(门面),Nginx 根据请求路径把请求转发给对应的应用容器,应用容器再去连接数据库和缓存。 ```mermaid graph TB subgraph "单台服务器 192.168.1.100" subgraph "Docker 环境" nginx["Nginx 容器<br/>反向代理 / 流量分发<br/>端口: 80 / 443"] app1["应用容器 1<br/>Spring Boot 服务<br/>端口: 8080"] app2["应用容器 2<br/>Spring Boot 服务<br/>端口: 8081"] redis["Redis 容器<br/>缓存服务<br/>端口: 6379"] mysql["MySQL 容器<br/>数据库<br/>端口: 3306"] end end user["用户请求"] --> nginx nginx -->|"转发 /api/"| app1 nginx -->|"转发 /api/"| app2 app1 --> redis app1 --> mysql app2 --> redis app2 --> mysql ``` **数据流解读**:用户访问网站 → 请求先到 Nginx(统一入口)→ Nginx 把 API 请求转发给 Spring Boot 容器 → Spring Boot 从 Redis 读缓存,缓存没有就去 MySQL 查 → 查到后返回给用户,同时写入 Redis 缓存。 ##### 前置准备 在开始部署之前,需要先在服务器上安装 Docker 和 Docker Compose。就像做饭之前要先把灶台和锅碗瓢盆准备好一样,这些是后续所有操作的基础。 | 步骤 | 命令 | 大白话解释 | |------|------|----------| | 安装 Docker | `curl -fsSL https://get.docker.com \| bash` | 从 Docker 官网拉一个安装脚本,自动帮你装好 Docker 引擎。就像让专业师傅上门装灶台,一条命令搞定 | | 安装 Docker Compose | `sudo yum install docker-compose -y` | Docker Compose 是 Docker 的"批量管家",让你用一个配置文件同时管理多个容器。没有它你就得一个一个手动启动 | | 验证安装 | `docker --version` | 看一眼版本号,确认安装成功了。如果报 `command not found`,说明没装上,回头检查 | | 启动 Docker 服务 | `sudo systemctl start docker` | Docker 装好了但还没运行,这条命令相当于"开灶" | | 设置开机自启 | `sudo systemctl enable docker` | 服务器重启后 Docker 自动启动,不用你每次手动开。生产环境**必须设置**,不然机器重启后服务就断了 | ##### Docker Compose 配置文件详解 这是整个部署的核心——`docker-compose.yml`。它就像一张"施工图纸",告诉 Docker 要启动哪些服务、每个服务怎么配置、服务之间怎么关联。下面逐段讲解,每一行都配上大白话解释。 ```yaml # version 表示 Compose 文件格式的版本号 # 不同版本支持的功能不同,3.8 是目前最常用的稳定版本 version: '3.8' # services 下面定义所有需要运行的容器 # 每一个 service 就是一个独立的容器,可以理解为"一台虚拟小电脑" services: # ---- MySQL 数据库服务 ---- # 数据库是整个应用的数据仓库,所有业务数据都存在这里 mysql: # image 指定使用哪个"系统盘"来创建容器 # mysql:8.0 就是一个已经装好 MySQL 8.0 的系统盘,拿来即用 image: mysql:8.0 # container_name 给容器起个名字,方便后续管理 # 不指定的话 Docker 会自动生成一个随机名字(如 mystifying_tesla),不好记 container_name: test-mysql # restart: always —— 容器挂了自动重启 # 比如数据库进程崩了,Docker 会自动把它拉起来,不用你半夜爬起来手动重启 restart: always # environment 设置容器内部的环境变量 # 这些变量在 MySQL 首次启动时生效,帮你自动完成初始化配置 environment: MYSQL_ROOT_PASSWORD: root123456 # root 超级用户的密码,权力最大 MYSQL_DATABASE: myapp # 首次启动自动创建这个数据库,省得你手动建 MYSQL_USER: appuser # 创建一个普通用户,日常操作用这个,不用 root MYSQL_PASSWORD: apppass123 # 普通用户的密码 # ports 端口映射:把容器内部的端口"暴露"到宿主机 # 格式是 "宿主机端口:容器端口" # "3306:3306" 意思是:访问服务器的 3306 端口,就等于访问容器内的 3306 端口 ports: - "3306:3306" # volumes 目录挂载:把宿主机的目录"绑定"到容器内部 # 这是数据持久化的关键——默认情况下,容器删除后内部数据就没了 # 挂载后,数据实际存在宿主机上,容器删了重建,数据还在 volumes: # 数据库文件存到宿主机 ./data/mysql 目录 # 容器内 /var/lib/mysql 是 MySQL 存数据的地方,映射出来就不怕丢了 - ./data/mysql:/var/lib/mysql # 把初始化 SQL 脚本挂载进去 # MySQL 首次启动时会自动执行这个目录下的 .sql 文件,帮你建表、灌初始数据 - ./init.sql:/docker-entrypoint-initdb.d/init.sql # ---- Redis 缓存服务 ---- # Redis 是内存数据库,用来缓存热点数据,减轻数据库压力 # 就像你把常用文件放在桌面,不用每次都去文件柜翻 redis: image: redis:7-alpine # alpine 版本基于 Alpine Linux,体积只有正常版的 1/5 container_name: test-redis restart: always ports: - "6379:6379" # command 覆盖容器默认的启动命令 # 默认 Redis 启动是不设密码的,这里加上密码防止被人白嫖 command: redis-server --requirepass redis123 volumes: - ./data/redis:/data # 把 Redis 的持久化文件也挂出来,防止丢失 # ---- 应用服务(Spring Boot 后端)---- # 这是你的核心业务应用,处理用户的请求、执行业务逻辑 app: # build 表示不从仓库拉镜像,而是根据 Dockerfile 自己构建 # 就像不买现成的家具,而是按图纸自己打 build: context: . # Dockerfile 在当前目录下 dockerfile: Dockerfile # 指定 Dockerfile 文件名 container_name: test-app restart: always ports: - "8080:8080" # depends_on 定义启动顺序 # 意思是:先启动 mysql 和 redis,再启动 app # 不然应用启动时连不上数据库就报错了 # 注意:depends_on 只保证启动顺序,不保证 mysql 已经"准备好接受连接" # 如果应用启动太快连不上数据库,可以在应用里加重试逻辑 depends_on: - mysql - redis # environment 注入环境变量 # 这些变量会覆盖 Spring Boot 配置文件中的对应项 # 好处:不用改代码和配置文件,只需改环境变量就能切换数据库连接等配置 environment: SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/myapp?useSSL=false&serverTimezone=Asia/Shanghai # 注意这里的 host 写的是 "mysql" 而不是 IP # 因为 Docker Compose 会自动创建一个内部网络,服务名就是域名 # 应用容器访问 "mysql:3306" 就能连到 MySQL 容器,不用管 IP SPRING_DATASOURCE_USERNAME: appuser SPRING_DATASOURCE_PASSWORD: apppass123 SPRING_REDIS_HOST: redis # 同理,"redis" 就是 Redis 容器的域名 SPRING_REDIS_PASSWORD: redis123 # ---- Nginx 反向代理 ---- # Nginx 是整个系统的"前台门面",用户只跟它打交道 # 它负责:1. 接收用户请求 2. 转发给后端应用 3. 返回结果给用户 # 这样用户只需要访问 80 端口,不用记住后端各种端口号 nginx: image: nginx:alpine container_name: test-nginx restart: always ports: - "80:80" # HTTP 端口,用户访问的就是这个 - "443:443" # HTTPS 端口,配置 SSL 后使用 volumes: - ./nginx.conf:/etc/nginx/nginx.conf:ro # :ro = read-only 只读挂载 # Nginx 配置文件挂进来,修改后 nginx -s reload 即可生效 # :ro 防止容器内的进程意外修改配置文件 depends_on: - app # 等应用启动后再启动 Nginx ``` > **关键概念说明**: > - **端口映射**(ports):容器是一个封闭的小世界,外部默认访问不到。端口映射就是在容器上"开一扇窗",让外部流量能进来。`"8080:8080"` 就是把容器的 8080 端口暴露到宿主机的 8080 端口。 > - **目录挂载**(volumes):容器内部的数据默认是临时的,容器一删就没了。挂载就是把宿主机的目录"绑定"到容器内,这样数据实际存在宿主机上,容器重建后数据还在。 > - **服务名即域名**:Docker Compose 自动创建内部网络,服务名就是域名。比如 `mysql:3306` 就能访问 MySQL 容器,不用写 IP。 ##### 应用 Dockerfile 详解 Dockerfile 是打包应用的"菜谱",告诉 Docker 怎么把你的源代码变成一个可运行的镜像。这里用的是**多阶段构建**——先在一个"厨房"里编译代码,再把编译好的"成品菜"端到另一个干净的"盘子"里。这样做的好处是最终镜像很小,不会把编译工具链也带进去。 ```dockerfile # ============ 阶段1:构建 ============ # 这个阶段就像一个"专用厨房",里面有 Maven 和 JDK,专门用来编译代码 # 构建完成后这个"厨房"就被丢弃了,不会出现在最终镜像里 FROM maven:3.9-eclipse-temurin-17 AS builder WORKDIR /app # 先复制 pom.xml 并下载依赖 # 这一步单独写,是为了利用 Docker 的缓存机制: # 只要 pom.xml 没变,下次构建就会跳过依赖下载,节省大量时间 COPY pom.xml . RUN mvn dependency:go-offline -B # 把所有依赖下载到本地缓存 # 再复制源代码并编译 COPY src ./src RUN mvn clean package -DskipTests -B # 打包成 JAR,跳过测试(测试在 CI 阶段已经跑过了) # ============ 阶段2:运行 ============ # 这是最终镜像,只包含运行应用所需的最小环境 # 用 JRE 而不是 JDK,因为运行时不需要编译器,体积小很多 FROM eclipse-temurin:17-jre-alpine WORKDIR /app # 安全最佳实践:在容器内创建一个普通用户来运行应用 # 默认容器以 root 用户运行,一旦被攻破,攻击者就拥有了 root 权限 # 用普通用户运行,即使被攻破,危害也有限 RUN addgroup -S appgroup && adduser -S appuser -G appgroup USER appuser # 从 builder 阶段把编译好的 JAR 包复制过来 # --from=builder 表示从名为 builder 的阶段复制 COPY --from=builder /app/target/*.jar app.jar # 健康检查:Docker 每 30 秒访问一次 /actuator/health 端点 # 连续 3 次失败就认为容器不健康,触发重启策略 # 就像定时给容器"量体温",发烧了就送医院 HEALTHCHECK --interval=30s --timeout=3s --retries=3 \ CMD wget -qO- http://localhost:8080/actuator/health || exit 1 # EXPOSE 声明容器对外提供服务的端口 # 这只是一个"文档说明",并不会实际发布端口,真正的端口映射在 docker-compose.yml 中 EXPOSE 8080 # ENTRYPOINT 容器启动时执行的命令 # 这里启动 Java 应用,配置了基本 JVM 参数 ENTRYPOINT ["java", \ "-Xms512m", "-Xmx1024m", \ # 初始/最大堆内存 "-Djava.security.egd=file:/dev/./urandom", \ # 加速 SecureRandom 初始化 "-jar", "app.jar"] ``` ##### 启动与运维 万事俱备,只欠东风。配置文件写好后,按照下面的流程图操作即可: ```mermaid graph TD A["编写 docker-compose.yml"] --> B["docker-compose up -d<br/>后台启动所有服务"] B --> C["docker-compose ps<br/>查看各容器运行状态"] C --> D{"服务是否正常?"} D -->|正常| E["docker-compose logs -f app<br/>实时查看应用日志"] D -->|异常| F["docker-compose logs mysql<br/>排查数据库日志"] F --> G["修改配置后<br/>docker-compose restart"] G --> C E --> H["测试完成<br/>docker-compose down<br/>停止并删除所有容器"] ``` | 命令 | 大白话解释 | |------|----------| | `docker-compose up -d` | "一键开火"——根据配置文件启动所有容器。`-d` 是后台运行的意思,不加的话当前终端会被占满,关掉终端服务就停了 | | `docker-compose ps` | "点名"——看看哪些容器在跑、哪些挂了,以及各自的端口映射 | | `docker-compose logs -f 服务名` | "监听"——实时查看某个服务的日志输出,`-f` 表示持续跟踪(跟 `tail -f` 一个意思),按 Ctrl+C 退出 | | `docker-compose restart 服务名` | "重启"——某个服务改了配置文件后,重启让新配置生效 | | `docker-compose down` | "收工"——停掉并删除所有容器和网络。**注意**:数据卷(volumes 挂载的目录)不会删,数据库数据还在 | | `docker-compose down -v` | "连根拔起"——连数据卷一起删!数据库数据永久丢失,慎用! | | `docker exec -it test-mysql bash` | "进入容器内部"——像远程登录一样进入 MySQL 容器的命令行,可以直接执行 SQL | --- #### 第二部分:生产级多节点集群部署 ##### 为什么需要多节点? 单节点部署有一个致命问题:**单点故障**。那台机器一旦宕机——不管是断电、硬盘坏了、还是网络中断——你的整个系统就瘫痪了。用户访问不了,订单下不了,数据查不到,损失可能按分钟计算。 生产环境必须做到**高可用**:多台机器协同工作,任何一台挂了,其他机器自动接管,用户完全感知不到。这就好比你开了一家店,只有一个收银员,他请假了店就得关门;但如果你有三个收银员,谁请假都不影响营业。 多节点集群要解决的问题有三个: 1. **应用层高可用**:部署多个应用实例,一个挂了其他还在 2. **数据库高可用**:主库挂了,从库自动升级为主库,数据不丢 3. **入口层高可用**:负载均衡器也要做双机热备,不能它自己成了单点 ##### 多节点集群架构 下面这张图展示了生产环境的完整集群架构。从上往下看:用户请求先到负载均衡层,再分发到应用集群,应用集群再去访问缓存和数据库。每一层都做了高可用设计,不存在单点故障。 ```mermaid graph TB subgraph "负载均衡层 — 系统大门" lb1["Keepalived + Nginx<br/>主负载均衡器<br/>192.168.1.10"] lb2["Keepalived + Nginx<br/>备负载均衡器<br/>192.168.1.11"] vip["虚拟 IP VIP<br/>192.168.1.100<br/>用户只访问这个 IP"] end subgraph "应用服务集群 — 干活的" app1["Node-1<br/>应用容器<br/>192.168.1.20"] app2["Node-2<br/>应用容器<br/>192.168.1.21"] app3["Node-3<br/>应用容器<br/>192.168.1.22"] end subgraph "缓存集群 — 记性好的" r1["Redis 主节点<br/>读写<br/>192.168.1.30"] r2["Redis 从节点<br/>只读备份<br/>192.168.1.31"] r3["Redis 从节点<br/>只读备份<br/>192.168.1.32"] sentinel["Redis Sentinel<br/>哨兵:监控主节点健康<br/>主节点挂了自动提拔从节点"] end subgraph "数据库集群 — 存档案的" m1["MySQL 主库<br/>读写<br/>192.168.1.40"] m2["MySQL 从库<br/>只读<br/>192.168.1.41"] m3["MySQL 从库<br/>只读<br/>192.168.1.42"] end subgraph "管理与监控 — 后勤保障" swarm["Swarm Manager<br/>集群总指挥"] monitor["Prometheus + Grafana<br/>监控面板 + 告警"] end user["用户请求"] --> vip vip -.->|"VIP 漂移"| lb1 vip -.->|"主挂了才切"| lb2 lb1 <-->|"VRRP 心跳检测<br/>互相确认对方还活着"| lb2 lb1 --> app1 lb1 --> app2 lb1 --> app3 app1 --> r1 app2 --> r1 app3 --> r1 r1 -->|"数据同步"| r2 r1 -->|"数据同步"| r3 sentinel -.->|"监控 & 故障转移"| r1 sentinel -.->|"监控"| r2 sentinel -.->|"监控"| r3 app1 --> m1 app2 --> m1 app3 --> m1 m1 -->|"主从复制"| m2 m1 -->|"主从复制"| m3 swarm -->|"调度管理"| app1 swarm -->|"调度管理"| app2 swarm -->|"调度管理"| app3 monitor -.->|"采集指标"| app1 monitor -.->|"采集指标"| app2 ``` ##### 集群组件职责说明(大白话版) | 组件 | 官方说法 | 大白话理解 | |------|----------|----------| | **Docker Swarm** | 容器集群编排工具 | 工地的**总调度**:决定哪个工人(节点)干什么活,有人请假自动找人顶上 | | **Keepalived + VIP** | 负载均衡高可用方案 | 公司的**总机号码**:对外只有一个号码(VIP),背后两台机器一主一备。主机正常时它接电话,主机挂了备机无缝接替,客户完全不知道换人了 | | **Nginx 集群** | 七层反向代理与负载均衡 | **前台接待员**:把来访的客户请求均匀分配给后台的工作人员(应用实例),谁闲就分给谁 | | **Redis 主从 + Sentinel** | 缓存高可用与自动故障转移 | **记性好的团队**:主节点负责记东西(读写),从节点抄一份备份。Sentinel 是"监工",盯梢主节点,一发现它倒下了立马提拔一个从节点上位 | | **MySQL 主从复制** | 数据库高可用与读写分离 | **档案室**:主库存原件(写),从库存复印件(只读)。写操作只找主库,读操作分摊给从库,既安全又快 | | **Prometheus + Grafana** | 指标采集、可视化与告警 | **体检中心**:Prometheus 定时给每台机器量体温、测血压(采集指标),Grafana 把数据画成图表,体温超标自动发短信告警 | ##### 第一步:初始化 Docker Swarm 集群 Docker Swarm 是 Docker 自带的集群管理工具,装了 Docker 就自带,不用额外安装。它的工作方式很简单:选一台机器当"管理者"(Manager),其他机器当"打工人"(Worker),管理者负责分配任务,打工人负责执行。 下面的时序图展示了从零搭建集群的完整过程: ```mermaid sequenceDiagram participant ops as 运维人员 participant mgr as Manager 节点<br/>192.168.1.10 participant w1 as Worker 节点1<br/>192.168.1.20 participant w2 as Worker 节点2<br/>192.168.1.21 ops->>mgr: docker swarm init --advertise-addr 192.168.1.10<br/>初始化集群,我当老大 mgr-->>ops: 返回加入命令和 Token<br/>(相当于入队通行证) ops->>w1: docker swarm join --token SWMTKN-xxx 192.168.1.10:2377<br/>拿着通行证加入集群 w1-->>mgr: 注册成功,我来干活了 ops->>w2: docker swarm join --token SWMTKN-xxx 192.168.1.10:2377<br/>你也加入 w2-->>mgr: 注册成功,我也来了 ops->>mgr: docker node ls<br/>看看队里都有谁 mgr-->>ops: 显示节点列表<br/>Manager: Ready<br/>Worker1: Ready<br/>Worker2: Ready ``` **命令详解(每条都讲清楚执行时机和效果):** | 命令 | 在哪台机器执行 | 大白话解释 | |------|----------|----------| | `docker swarm init --advertise-addr 192.168.1.10` | Manager 节点 | "我来当老大"——初始化集群。`--advertise-addr` 告诉其他节点"来找我报到"。执行后会输出一个 `docker swarm join` 命令,里面带着 Token,复制下来给其他机器用 | | `docker swarm join --token SWMTKN-xxx 192.168.1.10:2377` | Worker 节点 | "我来打工"——拿着通行证加入集群。2377 是 Swarm 管理通信的端口,Token 是安全凭证,防止随便什么机器都来加入 | | `docker node ls` | Manager 节点 | "点名"——查看所有节点的状态。能看到每个节点是 Manager 还是 Worker、是正常(Ready)还是掉线(Down) | | `docker node promote 节点名` | Manager 节点 | "提拔"——把 Worker 提升为 Manager,增加管理节点的冗余度。推荐至少 3 个 Manager 节点 | | `docker node update --availability drain 节点名` | Manager 节点 | "休假"——把节点标记为"排空"模式,上面的容器会自动迁移到其他节点。常用于给机器做维护升级 | ##### 第二步:部署服务栈 集群搭好了,接下来要把应用部署上去。这里用 Docker Compose 文件配合 Swarm 的 `deploy` 配置来实现多节点编排。和单节点的 `docker-compose.yml` 相比,最大的区别是多了 `deploy` 块——它告诉 Swarm 怎么在多台机器上分配容器。 ```yaml version: '3.8' services: app: image: registry.example.com/myapp:latest # 从私有镜像仓库拉取镜像 # deploy 块是 Swarm 模式专用配置 # 在普通 docker-compose up 中不生效,必须用 docker stack deploy 部署 deploy: # replicas 副本数量:同时运行几个应用实例 # 3 个副本分布在 3 台机器上,任何一台挂了,另外两台还能继续服务 replicas: 3 # update_config 滚动更新策略:怎么安全地更新到新版本 update_config: parallelism: 1 # 每次只更新 1 个副本,不会一口气全换 delay: 10s # 更新一个后等 10 秒,确认没问题再更新下一个 failure_action: rollback # 如果更新失败(新版本起不来),自动回滚到旧版本 # restart_policy 重启策略:容器挂了怎么处理 restart_policy: condition: on-failure # 只有异常退出才重启(正常退出不重启) max_attempts: 3 # 最多重试 3 次,避免无限重启(可能代码本身有问题) # placement 部署约束:容器应该放在哪种节点上 placement: constraints: - node.role == worker # 应用容器只部署在 Worker 节点上 # Manager 节点专注于管理工作,不跑业务,避免影响集群稳定性 environment: SPRING_PROFILES_ACTIVE: prod networks: - app-network nginx: image: nginx:alpine deploy: replicas: 2 # 2 个 Nginx 实例,互为备份 placement: constraints: - node.role == manager # Nginx 部署在 Manager 节点,作为统一入口 ports: - "80:80" - "443:443" configs: - source: nginx_config target: /etc/nginx/nginx.conf # configs 是 Swarm 专用的配置管理方式 # 比 volumes 更适合存储配置文件:可以版本化、可以滚动更新 configs: nginx_config: file: ./nginx-prod.conf # overlay 网络:允许不同机器上的容器互相通信 # 就像给分布在各楼层的员工配了对讲机,不用走公网 networks: app-network: driver: overlay ``` ##### 第三步:服务部署与运维命令 | 命令 | 大白话解释 | |------|----------| | `docker stack deploy -c docker-compose.yml myapp` | "全员上阵"——把配置文件中定义的所有服务部署到集群上。Swarm 会自动把容器分配到各个节点。`myapp` 是这个技术栈的名字,后续操作都靠它 | | `docker stack ls` | "看看部署了什么"——列出集群中所有的技术栈 | | `docker stack services myapp` | "看看各服务状态"——显示每个服务需要几个副本、当前跑了几个。如果 `REPLICAS` 显示 `3/3` 说明全在跑,`2/3` 说明有一个挂了 | | `docker stack ps myapp` | "细看每个副本"——显示每个副本具体跑在哪台机器上、运行了多久、是否正常 | | `docker service logs -f myapp_app` | "看聚合日志"——把分布在多台机器上的应用日志汇总显示,不用一台一台登录看 | | `docker service scale myapp_app=5` | "紧急加人"——把应用副本从 3 个扩到 5 个,应对流量突增。Swarm 自动在可用节点上启动新容器 | | `docker service update --image registry.example.com/myapp:v2.0 myapp_app` | "升级装备"——把应用镜像更新到 v2.0 版本。Swarm 会按 `update_config` 中的策略逐个替换,保证服务不中断 | | `docker stack rm myapp` | "收队"——移除整个技术栈,所有容器停止并删除 | ##### 第四步:多节点日常运维 集群部署不是一锤子买卖,日常巡检和及时处理问题同样重要。下面这张图展示了一个典型的日常运维流程: ```mermaid graph TD A["每日巡检<br/>docker node ls<br/>检查所有节点状态"] --> B{"所有节点正常?"} B -->|是| C["查看服务状态<br/>docker stack services myapp"] B -->|否| D["排查故障节点<br/>docker node inspect 节点名"] D --> E["必要时驱逐节点<br/>docker node update --availability drain<br/>让容器迁走,安心修机器"] E --> F["修复后重新加入<br/>docker node update --availability active"] F --> A C --> G["查看 Grafana 监控面板"] G --> H{"资源使用率超阈值?"} H -->|是| I["扩容<br/>docker service scale myapp_app=N<br/>增加副本来分担压力"] H -->|否| J["记录巡检日志<br/>一切正常,收工"] ``` **关键概念深入讲解**: **节点故障自动恢复**:当某个 Worker 节点宕机,Swarm 不需要你做任何事——它会自动发现该节点失联,然后在该节点上运行的容器迁移到其他健康节点。整个过程通常在几十秒内完成。这得益于 Swarm 的"期望状态协调"机制:你声明"我要 3 个应用副本",Swarm 会持续监控,发现只有 2 个在跑,就自动补上 1 个。就像你跟管家说"家里要常备 3 瓶牛奶",管家发现只剩 2 瓶了就自动去超市补货。 **滚动更新的原理**:更新时绝对不能把旧版本全停了再启动新版本(那叫"停机更新",用户会看到 502 错误)。滚动更新是逐个替换:先启动 1 个新版本容器 → 等健康检查通过 → 停掉 1 个旧版本容器 → 再启动第 2 个新版本容器……如此循环,直到全部替换完毕。用户在这个过程中完全感知不到服务中断。 **日志聚合**:在多节点集群中,同一个服务的 3 个副本可能分布在 3 台不同的机器上。`docker service logs` 会自动把所有副本的日志汇总到一起显示,不用你逐台 SSH 登录去看。 **监控告警**:推荐用 Prometheus 定时采集各节点的 CPU、内存、磁盘、网络指标,Grafana 以漂亮的图表展示。当某个指标超过阈值(比如内存使用率超过 90%),Alertmanager 会自动通过钉钉、邮件或短信发送告警。运维人员不需要一直盯着屏幕,有问题系统会主动通知你。 --- #### 测试环境 vs 生产环境对比总结 | 维度 | 测试环境单节点 | 生产环境多节点集群 | |------|---------------|-------------------| | 服务器数量 | 1 台 | 至少 3 台(推荐 5 台以上) | | 容器编排 | Docker Compose | Docker Swarm | | 高可用 | 无,单点故障 | 多副本自动故障转移 | | 数据持久化 | 本地目录挂载 | NFS/分布式存储 | | 负载均衡 | 无或单 Nginx | Nginx + Keepalived 双活 | | 数据库 | 单实例 | 主从复制 + 读写分离 | | 缓存 | 单 Redis | Redis 主从 + Sentinel | | 监控告警 | 手动查看日志 | Prometheus + Grafana 自动告警 | | 日志管理 | docker logs | ELK/Loki 集中日志平台 | | 适用场景 | 开发调试、功能验证 | 正式对外提供服务 | > **选型建议**:如果你的项目是内部系统、用户量不大,单节点就够用了,别过度设计。等流量上来了、业务重要了,再升级到多节点也不迟。架构是演出来的,不是一步到位的。 ### 2.5 JVM 调优参考 | 参数 | 说明 | 推荐值 | |------|------|--------| | `-Xms` | 初始堆内存 | 物理内存的 1/4 | | `-Xmx` | 最大堆内存 | 物理内存的 1/2,不超过 4G | | `-XX:+UseG1GC` | 使用 G1 垃圾回收器 | JDK 9+ 默认,低延迟场景推荐 | | `-XX:MaxRAMPercentage=75.0` | 容器内按比例分配堆内存 | Docker 环境推荐 | | `-XX:+HeapDumpOnOutOfMemoryError` | OOM 时自动 dump 堆 | **生产必开**,排查内存问题 | | `-XX:HeapDumpPath` | dump 文件路径 | 指向 logs 目录 | ```bash # 脚本部署中的推荐 JVM 参数(已在 app.sh 中配置) JVM_OPTS="-Xms512m -Xmx1024m" JVM_OPTS="$JVM_OPTS -XX:+UseG1GC" JVM_OPTS="$JVM_OPTS -XX:+HeapDumpOnOutOfMemoryError" JVM_OPTS="$JVM_OPTS -XX:HeapDumpPath=$LOG_DIR/heapdump.hprof" ``` ```bash # Docker 环境下的推荐启动参数 java -XX:MaxRAMPercentage=75.0 \ -XX:+UseG1GC \ -XX:+HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath=/app/logs/heapdump.hprof \ -jar app.jar ``` --- ## 三、Python(FastAPI / Flask)项目部署  ### 先说大白话:Python 项目部署和 Java 有什么不同? 很多学 Java 出身的同学第一次部署 Python 项目会懵:**"Python 没有 `main` 方法、没有内置 Web 服务器,我到底在跑什么?"** 先把这个困惑讲清楚 —— **Java Spring Boot 自带 Tomcat**,打个 JAR 包直接 `java -jar` 就能跑;但 **Python 不是这样**,它是解释型语言,没有"自带服务器"这个概念。要跑一个 HTTP API 服务,你需要自己搭一个 Web 服务器来"托管"你的 Python 代码。 这就引出了一整套工具和概念,我们用一张图来讲清楚: ```mermaid graph LR A["用户请求"] --> B["Nginx<br/>反向代理"] B --> C["Gunicorn / Uvicorn<br/>WSGI / ASGI 服务器"] C --> D["FastAPI / Flask<br/>你的 Python 应用代码"] D --> E["数据库 / Redis"] ``` **各层大白话解释**: | 层级 | 是什么 | 大白话理解 | 为什么需要 | |------|--------|------------|----------| | **Nginx** | HTTP 反向代理服务器 | "前台接待员"——负责接客、分发请求、返回结果 | 直接把 Python 服务暴露到公网不安全,Nginx 做了一层缓冲和统一入口 | | **Gunicorn** | WSGI HTTP 服务器 | "包工头"——负责启动和管理多个 Python 工作进程(Worker) | Python 代码自己跑不稳,需要个"包工头"来管理进程,挂了自动重启 | | **Uvicorn** | ASGI 服务器(FastAPI 专用)| "技术工"——真正执行你的 async/await 代码的服务器 | FastAPI 基于 async/await,必须用支持 ASGI 的服务器才能发挥性能 | | **FastAPI/Flask** | 你的业务代码 | "干活的程序员"——写接口、写业务逻辑的地方 | 这是你真正要部署的东西 | > **一句话总结**:部署 Python 项目 = 让你的代码跑在一个"包工头(Gunicorn)"管理下的"技术工(Uvicorn/Flask)"进程里,前面再挡一个"前台(Nginx)"。 --- ### 3.1 项目结构示例 一个标准的 Python API 项目长这样,每个目录和文件都有明确分工: ``` my-python-api/ ├── app/ │ ├── __init__.py # 包初始化文件(Python 包必须有这个) │ ├── main.py # 应用入口:FastAPI() 或 Flask() 实例创建的地方 │ ├── routers/ # 路由模块(接口定义放这里) │ │ └── user.py │ ├── models/ # 数据模型(Pydantic / SQLAlchemy 模型) │ │ └── user.py │ └── dependencies.py # 依赖注入(认证、数据库会话等) ├── requirements.txt # 依赖清单(pip install -r 安装的包列表) ├── gunicorn.conf.py # Gunicorn 配置文件(Worker 数、超时等) ├── Dockerfile # Docker 构建文件 └── .env # 环境变量(数据库密码、API Key 等,不要提交到 Git!) ``` **为什么要这样组织?** - `app/` 是一个 Python 包,所有业务代码都在里面 - `requirements.txt` 是 Python 项目的"购物清单"——列出所有需要安装的第三方库(类似 Java 的 `pom.xml`) - `gunicorn.conf.py` 控制 Gunicorn 怎么跑你的应用:几个进程、超时多久、日志在哪 - `.env` 存敏感信息,通过 `python-dotenv` 库加载,**切记加入 `.gitignore`**,不要提交到代码仓库 --- ### 3.2 方式一:虚拟环境直接部署 "虚拟环境"是 Python 的生态特色——它解决了"全局 Python 被不同项目互相污染"的问题。比如项目 A 需要 `requests==2.28`,项目 B 需要 `requests==2.31`,装在一起会冲突。虚拟环境就是给每个项目建一个**独立的 Python 小房间**,互不干扰。 #### 部署步骤详解 跟着下面这张流程图操作,每一步都有大白话解释: ```mermaid graph TD A["第一步:上传代码<br/>scp / git clone"] --> B["第二步:创建虚拟环境<br/>python3 -m venv venv"] B --> C["第三步:激活虚拟环境<br/>source venv/bin/activate"] C --> D["第四步:安装依赖<br/>pip install -r requirements.txt"] D --> E["第五步:测试启动<br/>看有没有报错"] E --> F{"启动成功?"} F -->|是| G["第六步:用 Gunicorn 启动<br/>正式跑起来"] F -->|否| H["排查错误<br/>看报错信息"] H --> D G --> I["第七步:配置 systemd<br/>开机自启 + 崩溃重启"] ``` **第一步:上传代码到服务器** ```bash # 方式 A:从 Git 仓库拉取(推荐,版本可控) git clone https://github.com/yourname/my-python-api.git /opt/myapi cd /opt/myapi git checkout prod # 切换到生产分支 # 方式 B:用 scp 从本地上传(适合没用 Git 的项目) # 在本地机器执行: scp -r ./my-python-api user@your-server:/opt/myapi ``` **第二步:创建虚拟环境** ```bash cd /opt/myapi # venv 是 Python 内置的虚拟环境工具,会在当前目录创建一个 venv/ 文件夹 # 里面是一个独立的 Python 解释器 + pip,跟系统 Python 完全隔离 python3 -m venv venv # 验证:激活后命令行提示符前面会出现 (venv) 字样 source venv/bin/activate which python # 应该显示 /opt/myapi/venv/bin/python(不是 /usr/bin/python) ``` > **大白话**:`venv` 就像给这个项目单独配了一台"虚拟机",里面的 Python 是独立的,跟系统 Python 各装各的包,互不干扰。 **第三步:安装依赖** ```bash # 激活虚拟环境后,pip 会自动把包装到 venv/ 里(不是装到系统) source venv/bin/activate pip install -r requirements.txt # 安装完成后,确认关键包都在 pip list | grep -E "fastapi|flask|gunicorn|uvicorn" ``` > **注意**:FastAPI 生产环境需要额外装 `gunicorn` 和 `uvicorn[standard]` > ```bash > pip install gunicorn "uvicorn[standard]" > ``` **第四步:先测试启动,确认能跑起来** ```bash # ---------- FastAPI 项目 ---------- # uvicorn 是 FastAPI 的开发服务器,先用它测一下能不能跑 # --reload 表示代码改了自动重启(仅开发用,生产不要用!) uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload # 如果看到 "Uvicorn running on http://0.0.0.0:8000" 说明成功了 # 按 Ctrl+C 停掉,准备用 Gunicorn 正式跑 # ---------- Flask 项目 ---------- # Flask 内置的开发服务器(app.run)不能用于生产!只用来测试 flask --app app.main run --host 0.0.0.0 --port 8000 ``` > **为什么开发服务器不能用于生产?** 因为 `uvicorn --reload` 或 `flask run` 是单线程、单进程的,只能同时处理一个请求,并发能力几乎为零,而且没有进程守护,挂了就是真挂了。 **第五步:用 Gunicorn 正式启动(生产方式)** 现在要用 Gunicorn("包工头")来管理你的应用进程了: ```bash # ========== FastAPI 项目 ========== gunicorn app.main:app \ -w 4 \ # 启动 4 个 Worker 进程(具体几个看下面说明) -k uvicorn.workers.UvicornWorker \ # Worker 类型:FastAPI 必须用 UvicornWorker --bind 0.0.0.0:8000 \ # 监听所有网卡的 8000 端口 --timeout 120 \ # Worker 处理请求超过 120 秒就被强制杀掉重启 --access-logfile - \ # 访问日志输出到 stdout(Docker 场景推荐) --error-logfile - # 错误日志输出到 stdout # ========== Flask 项目 ========== gunicorn app.main:app \ -w 4 \ # 4 个 Worker 进程 -k gthread \ # Worker 类型:Flask 推荐用线程模式 --bind 0.0.0.0:8000 \ --timeout 120 ``` **`-w 4` 是什么意思?Worker 数量怎么定?** 这是最容易困惑的地方,用大白话讲:`gunicorn` 启动后是这样的: ``` Gunicorn(主进程,包工头) ├── Worker 1(干活的程序员) ├── Worker 2(干活的程序员) ├── Worker 3(干活的程序员) └── Worker 4(干活的程序员) ``` 每个 Worker 是一个独立的 Python 进程,能同时处理请求。**Worker 越多 = 并发能力越强**,但也不是越多越好(内存有限)。 **推荐公式**:`Worker 数 = (2 × CPU核心数) + 1` 比如你的服务器是 2 核 CPU,就设 `2 × 2 + 1 = 5` 个 Worker。4 核就设 9 个。 #### Gunicorn 配置文件(推荐用文件代替命令行) 每次启动敲一大串参数太麻烦,也容易出错。推荐把配置写入 `gunicorn.conf.py` 文件: ```python # gunicorn.conf.py # Gunicorn 会自动读取当前目录下的这个文件,不需要在命令行指定 -c import multiprocessing import os # ==================== 核心配置 ==================== # Worker 数量:公式 (2 × CPU核心数) + 1 # multiprocessing.cpu_count() 自动获取 CPU 核心数,不用硬编码 workers = multiprocessing.cpu_count() * 2 + 1 # 每个 Worker 的线程数(Flask 的 gthread 模式会用到,FastAPI 一般设 1) # FastAPI 是 async 的,一个进程就能处理大量并发,不需要多线程 # Flask 是同步的,一个请求占用一个线程,所以线程数可以设大一点(如 4) threads = 1 # FastAPI 用 1;Flask 用 4 # Worker 类型(非常重要!决定了你的应用怎么运行) # FastAPI → 必须用 "uvicorn.workers.UvicornWorker"(支持 async/await) # Flask → 推荐用 "gthread"(多线程模式,能更好利用 CPU) worker_class = "uvicorn.workers.UvicornWorker" # FastAPI 用这行 # worker_class = "gthread" # Flask 用这行(取消注释,注释掉上一行) # ==================== 网络配置 ==================== # 绑定地址:0.0.0.0 表示监听所有网卡(外网能访问) # 127.0.0.1 表示只监听本机(外网访问不了,一般不用) bind = "0.0.0.0:8000" # ==================== 超时配置 ==================== # Worker 处理请求的超时时间(秒) # 超过这个时间 Worker 还没处理完,Gunicorn 会强制 KILL 掉这个 Worker 并重启 # 设置太短:正常请求被误杀;设置太长:慢请求卡住 Worker 不释放 # 一般 API 接口 30-60 秒,有文件处理/导出的可以设 120-300 秒 timeout = 120 # 优雅重启超时(秒) # 当你 reload Gunicorn 时,旧 Worker 有这么多秒时间处理完手上的请求再退出 # 设为 0 表示立即强制退出(可能丢请求);设为 30 表示给 30 秒优雅收尾 graceful_timeout = 30 # ==================== 内存保护 ==================== # 每个 Worker 处理满 N 个请求后,自动重启自己 # 目的:防止代码里内存泄漏累积(Python 的 GC 不是万能的) # 比如设为 5000:每处理 5000 个请求,这个 Worker 就"自杀"重启,释放内存 max_requests = 5000 max_requests_jitter = 500 # 加一点随机抖动,避免所有 Worker 同时重启 # ==================== 日志配置 ==================== # 访问日志:记录每个请求的 URL、状态码、耗时 # "-" 表示输出到 stdout(Docker / systemd 场景推荐,日志由 Docker/systemd 统一管理) # 也可以写成文件路径:"/var/log/myapi/access.log" accesslog = "-" # 错误日志:记录异常、报错信息 errorlog = "-" # 日志级别:debug / info / warning / error / critical # 生产环境推荐 info 或 warning;debug 会打印太多日志影响性能 loglevel = "info" # ==================== 应用配置 ==================== # 预加载应用(True = 在 Worker fork 之前就把应用代码加载好) # 好处:多个 Worker 共享同一份代码内存,节省内存 # 坏处:代码里的全局变量在 fork 后不共享(每个 Worker 有自己的副本) # 一般设为 True;如果你的应用有全局状态且依赖共享内存,设为 False preload_app = True # 进程名称(方便用 ps 命令识别) proc_name = "myapi-gunicorn" ``` #### 配置 systemd 服务(开机自启 + 崩溃重启) 直接 `gunicorn ...` 启动的进程,服务器重启后就停了,而且进程挂了不会自动重启。用 `systemd` 来管理就能解决这两个问题: ```ini # /etc/systemd/system/myapi.service # systemd 是 Linux 的系统服务管理器,用来管理开机自启和进程守护 [Unit] Description=My Python API Service # After=network.target 表示:等网络服务就绪后再启动本服务(不然端口绑定会失败) After=network.target [Service] Type=notify # User / Group:用普通用户运行,不要用 root(安全!) User=www-data Group=www-data # WorkingDirectory:进程的工作目录(gunicorn.conf.py 要放在这个目录下) WorkingDirectory=/opt/myapi # Environment:设置环境变量,重点是让 systemd 知道去哪找 venv 里的 python/gunicorn Environment="PATH=/opt/myapi/venv/bin" # 如果有 .env 文件,可以通过 EnvironmentFile 加载: # EnvironmentFile=/opt/myapi/.env # ExecStart:启动命令(不需要 -c gunicorn.conf.py,因为 Gunicorn 会自动找同目录下的) ExecStart=/opt/myapi/venv/bin/gunicorn app.main:app # Restart=always:进程挂了就自动重启(always = 任何原因退出都重启) # RestartSec=5:重启前等 5 秒(避免频繁重启刷日志) Restart=always RestartSec=5 # 限制资源(防止内存泄漏拖垮整台服务器) # MemoryMax=1G:这个服务最多用 1GB 内存,超了会被系统杀掉 MemoryMax=1G [Install] # WantedBy=multi-user.target:多用户模式下自动启动(即正常的服务器运行级别) WantedBy=multi-user.target ``` 启用并启动服务: ```bash # 重新加载 systemd 配置(每次改了 .service 文件都要执行) sudo systemctl daemon-reload # 启动服务 sudo systemctl start myapi # 设置开机自启 sudo systemctl enable myapi # 查看状态(看是否 Active (running)) sudo systemctl status myapi # 查看日志(代替 tail -f /var/log/xxx.log) sudo journalctl -u myapi -f ``` --- ### 3.3 方式二:Docker 容器化部署(推荐) 如果你的团队已经用 Docker 管理 Java/前端项目,Python 项目也用 Docker 部署是最省心的——环境完全一致,不会再有"我本地能跑"的问题。 #### FastAPI 项目的 Dockerfile(逐行大白话注释) ```dockerfile # ==================== 阶段 1:安装依赖("厨房"阶段)==================== # 用 python:3.12-slim 作为构建环境 # slim 版本比 full 版本体积小很多(约 50MB vs 800MB),生产推荐 FROM python:3.12-slim AS builder # WORKDIR:设置工作目录为 /app(类似 cd /app,后面的命令都在这个目录下执行) WORKDIR /app # 先只复制 requirements.txt,再执行 pip install # 目的:利用 Docker 的缓存机制 # requirements.txt 不常变,这样依赖装好后,下次构建会直接用缓存,不用重新下载 COPY requirements.txt . # --no-cache-dir:不缓存下载的安装包(减小镜像体积) # --prefix=/install:把包装到 /install 目录下(方便下一阶段复制) RUN pip install --no-cache-dir --prefix=/install -r requirements.txt # ==================== 阶段 2:运行("上菜"阶段)==================== # 这个阶段是最终镜像,只保留运行所需的最小文件 FROM python:3.12-slim WORKDIR /app # 安全:创建非 root 用户来运行应用 # 默认用 root 跑容器,一旦被攻破,攻击者就有 root 权限,很危险 RUN groupadd -r appgroup && useradd -r -g appgroup appuser # 从 builder 阶段把安装好的依赖复制过来 # /install 目录下的所有文件会被复制到新镜像的 /usr/local 下 COPY --from=builder /install /usr/local # 复制应用代码到容器 COPY . . # 切换到普通用户(安全最佳实践) USER appuser # 健康检查:Docker 每隔一段时间访问一次 /health 接口 # 连续失败 3 次,Docker 就认为这个容器"不健康",触发重启策略 HEALTHCHECK --interval=30s --timeout=3s --retries=3 \ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" # 声明容器对外暴露 8000 端口(文档作用,实际映射在 docker run -p 时指定) EXPOSE 8000 # 容器启动命令:用 Gunicorn 跑应用 # -w 4:4 个 Worker(容器内 CPU 核心数就是容器被分配的核数,可以用 os.cpu_count() 获取) # --bind 0.0.0.0:监听所有网卡(容器内必须写 0.0.0.0,写 127.0.0.1 外网访问不了) CMD ["gunicorn", "app.main:app", \ "-k", "uvicorn.workers.UvicornWorker", \ "-w", "4", \ "--bind", "0.0.0.0:8000", \ "--timeout", "120"] ``` #### Flask 项目的 Dockerfile(只改 CMD) Flask 项目唯一区别是 Worker 类型不同,Gunicorn 用 `gthread` 模式: ```dockerfile # ... 前面的 FROM / WORKDIR / COPY 都和 FastAPI 完全一样 ... CMD ["gunicorn", "app.main:app", \ "-w", "4", \ "--bind", "0.0.0.0:8000", \ "--timeout", "120"] # 注意:Flask 不需要 -k 参数,默认就是 sync worker # 如果想用多线程模式,加:-k gthread -t 4 ``` #### 构建与运行 ```bash # 构建镜像(在项目根目录执行,确保 Dockerfile 在当前目录) docker build -t myapi:latest . # 运行容器 docker run -d \ --name myapi \ -p 8000:8000 \ # 注入环境变量(覆盖代码中的配置,比改代码灵活) -e DATABASE_URL=postgresql://user:pass@db:5432/mydb \ -e REDIS_URL=redis://redis:6379/0 \ -e TZ=Asia/Shanghai \ # 挂载 .env 文件(适合不方便用 -e 注入的大量环境变量) -v /opt/myapi/.env:/app/.env:ro \ # 自动重启策略:除非手动停止,否则一直重启 --restart unless-stopped \ myapi:latest # 查看日志 docker logs -f myapi # 进入容器内部调试 docker exec -it myapi bash ``` --- ## 四、前端(Vue / React)项目部署  ### 4.1 核心认知 > **前端部署的本质**:把源码构建成静态文件(HTML/CSS/JS),然后交给 Web 服务器(Nginx)对外提供服务。前端项目本身不需要运行时环境,Nginx 托管静态文件即可。 ### 4.2 项目构建 ```bash # ---- Vue 项目(Vite 构建)---- npm install npm run build # 产物在 dist/ 目录 # ---- React 项目(Vite / CRA)---- npm install npm run build # 产物在 build/ 或 dist/ 目录 ``` 构建完成后,核心产物是 `index.html` + JS/CSS 静态资源文件。 ### 4.3 方式一:Nginx 直接托管(最常用) #### 前端打包后到底生成了什么? 很多小白搞不清楚"构建"到底做了什么。用大白话来说: > **构建 = 把你能看懂的 Vue/React 源码,翻译成浏览器能直接运行的 HTML + CSS + JS 文件** 构建完成后,`dist/` 文件夹里就是这些"翻译好"的文件: ``` dist/ ├── index.html ← 入口页面,浏览器第一个加载的文件 ├── assets/ │ ├── index-abc123.js ← 你的业务代码(登录、下单等功能),带 hash 防缓存 │ ├── vendor-def.js ← 第三方库(Vue、React 等) │ └── style-xyz789.css ← 样式文件 └── favicon.ico ``` **核心认知**:Nginx 的工作就是"把这些文件发给浏览器",它不需要懂 Vue 或 React,它只负责"递文件"。 --- #### 第一步:把 dist 上传到服务器,放在哪里? 推荐放在 `/var/www/项目名/` 或 `/opt/项目名/frontend/`,两个方案对比: | 方案 | 路径示例 | 适用场景 | 优点 | |------|---------|---------|------| | `/var/www/` | `/var/www/myapp/` | 纯前端项目,Nginx 直接托管 | 符合 Linux 规范,权限清晰 | | `/opt/项目名/` | `/opt/myapp/frontend/dist/` | 前后端在同一台机器 | 前后端文件在一起,方便管理 | **推荐用 `/var/www/` 方案**,命令如下: ```bash # 在本地构建(你的电脑上) npm run build # 构建完成后,dist/ 目录就生成了 # 把 dist/ 里的所有文件上传到服务器的 /var/www/myapp/ # scp 是 SSH 文件传输命令,把本地文件复制到远程服务器 scp -r dist/* user@your-server-ip:/var/www/myapp/ # ---- 或者,在服务器上直接构建(推荐) ---- ssh user@your-server-ip cd /var/www/myapp git pull origin main # 拉最新代码 npm install # 安装依赖 npm run build # 构建,产物在 dist/ ``` > **小贴士**:如果 `npm run build` 时提示 `npm: command not found`,说明服务器上还没装 Node.js,参考"一、部署前置"的 1.2 节安装。 --- #### 第二步:Nginx 配置托管——逐行大白话解释 Nginx 要做的只有一件事:**当用户访问你的网站时,把 `dist/` 里的文件发给浏览器**。 下面是完整配置,每一行都配有"大白话解释": ```nginx # /etc/nginx/conf.d/myapp.conf # 这是 Nginx 的"虚拟主机"配置文件 # 一个文件对应一个网站,可以有多个网站同时跑在 80 端口 server { # listen 80:监听 80 端口(HTTP 默认端口) # 用户访问 http://你的域名 或 http://服务器IP,Nginx 就会收到请求 listen 80; # server_name:这个配置响应哪个域名的请求 # 比如 server_name www.example.com,只有访问 www.example.com 才会走这个配置 # 如果写 _(下划线),表示"匹配所有域名",适合临时测试 server_name www.example.com; # root:指定"网站根目录" # Nginx 收到请求后,会去这个目录下找文件 # 比如用户访问 /logo.png,Nginx 就去 /var/www/myapp/logo.png 找 root /var/www/myapp; # index:指定"默认首页" # 用户访问 / 时,Nginx 自动返回 index.html index index.html; # ---- 最关键配置:SPA 路由支持 ---- # 问题:Vue/React 是单页应用,路由是前端控制的 # 比如用户直接访问 /user/123,Nginx 会去找 /var/www/myapp/user/123 这个文件 # 但这个文件不存在!因为是前端路由,实际只有一个 index.html # # try_files 的作用: # 1. 先找 $uri(比如 /user/123 这个文件)——找不到 # 2. 再找 $uri/(比如 /user/123/ 目录)——找不到 # 3. 最后返回 /index.html(交给前端路由处理) # # 这一步是前端部署最容易出错的地方!配错了就会出现"刷新页面 404" location / { try_files $uri $uri/ /index.html; } # ---- 静态资源缓存策略 ---- # 带 hash 的 JS/CSS 文件(比如 index-abc123.js)内容不会变 # 浏览器可以缓存 1 年,下次直接读本地,不用再下载 location /assets/ { # expires 1y:告诉浏览器"这个文件 1 年内不用再来问我有没有更新" expires 1y; # Cache-Control "public, immutable":公开缓存,且内容不可变(因为有 hash) add_header Cache-Control "public, immutable"; } # ---- index.html 不缓存 ---- # index.html 是入口文件,每次发版都可能变 # 必须让浏览器每次都来问服务器"有没有新版本" location = /index.html { # expires -1:过期时间是"过去"(立即过期),浏览器每次都重新请求 expires -1; add_header Cache-Control "no-cache, no-store, must-revalidate"; } # ---- Gzip 压缩 ---- # 把 JS/CSS 文件压缩后再发给浏览器,传输速度快 3-5 倍 gzip on; # gzip_types:指定哪些类型的文件需要压缩 # text/plain text/css application/javascript 是前端最常用的 gzip_types text/plain text/css application/json application/javascript text/xml; # gzip_min_length:小于 1024 字节的文件不压缩(压缩收益太低) gzip_min_length 1024; # ---- 安全头(可选但推荐)---- # 防止被嵌入 iframe 钓鱼 add_header X-Frame-Options "SAMEORIGIN" always; # 防止浏览器猜测 MIME 类型(安全加固) add_header X-Content-Type-Options "nosniff" always; } ``` **配置文件写完后,让 Nginx 重新加载配置:** ```bash # 先检查配置文件语法是否正确(很重要!写错了 Nginx 会启动失败) sudo nginx -t # 语法 OK 后,平滑重载配置(不会中断正在处理的请求) sudo nginx -s reload ``` --- #### 部署后验证 Checklist ```mermaid graph TD A["部署完成"] --> B{"浏览器访问<br/>http://服务器IP"} B -->|能看到页面| C{"点击页面内的链接<br/>能正常跳转?"} B -->|白屏/404| D["检查 Nginx 错误日志<br/>sudo tail -f /var/log/nginx/error.log"] C -->|能跳转| E{"刷新浏览器<br/>页面是否正常?"} C -->|404| F["检查 try_files 配置<br/>是不是漏写了 /index.html"] E -->|正常| G["✅ 部署成功!"] E -->|404| H["检查 Nginx 配置<br/>try_files 是否正确"] D --> I["根据错误日志排查"] ``` | 现象 | 原因 | 解决办法 | |------|------|----------| | 访问 IP 白屏 | Nginx 没启动 / root 路径写错 | `sudo systemctl status nginx` 检查状态 | | 刷新页面 404 | `try_files` 配置缺失或写错 | 确认 `location /` 块里有 `try_files $uri $uri/ /index.html;` | | 样式错乱/JS 报错 | `dist/` 上传不完整 | 重新 `scp -r dist/*` 上传,或服务器上重新 `npm run build` | | 能访问但很慢 | 没开 Gzip / 图片没压缩 | 检查 `gzip on;` 是否配置,用浏览器 DevTools Network 面板看传输大小 | --- ### 4.4 方式二:Docker 容器化部署(推荐生产环境) #### 这种方式本质是什么? > **用 Docker 跑一个 Nginx 容器,把前端打包好的 HTML/CSS/JS 文件"塞"进去,让容器里的 Nginx 对外提供服务。** 和"方式一"的核心区别: - **方式一**:Nginx 直接装在服务器上,dist 文件放在服务器的目录里 - **方式二**:Nginx 跑在 Docker 容器里,dist 文件要么"打包进镜像",要么"挂载到容器里" 两种子方案对比: | 子方案 | 做法 | 适用场景 | |--------|------|----------| | **A. 打包进镜像**(推荐) | `dist/` 在构建镜像时就复制进去 | CI/CD 自动化部署,版本可追溯 | | **B. 挂载数据卷** | 容器启动后,把宿主机 `dist/` 挂载到容器里 | 快速调试,改了代码不用重新构建镜像 | --- #### 子方案 A:打包进镜像(生产推荐) **完整 Dockerfile(逐行大白话注释):** ```dockerfile # ============ 阶段1:构建前端代码 ============ # 用一个装好 Node.js 的"厨房"来构建项目 FROM node:20-alpine AS builder WORKDIR /app # 先复制 package*.json,再 npm ci # 目的:利用 Docker 缓存,package.json 没变就不重新下载依赖 COPY package*.json ./ RUN npm ci --registry=https://registry.npmmirror.com # 复制所有源代码,然后构建 COPY . . RUN npm run build # 构建完成后,产物在 /app/dist 目录 # ============ 阶段2:用 Nginx 托管 ============ # 换一个"干净盘子":只装 Nginx,不装 Node.js(镜像更小) FROM nginx:1.25-alpine # 删除 Nginx 默认配置(我们不想要那个 "Welcome to nginx!" 页面) RUN rm /etc/nginx/conf.d/default.conf # 把我们写好的 Nginx 配置文件复制进去 # nginx.conf 需要和 Dockerfile 在同一个目录 COPY nginx.conf /etc/nginx/conf.d/default.conf # 把阶段1构建好的 dist/ 复制进 Nginx 的默认网站目录 # /usr/share/nginx/html 是 Nginx 容器的默认 root 路径 COPY --from=builder /app/dist /usr/share/nginx/html # 暴露 80 端口(这只是"文档说明",真正映射端口在 docker run 时指定) EXPOSE 80 # 启动 Nginx(daemon off 表示"前台运行",Docker 容器需要一个前台进程才不会退出) CMD ["nginx", "-g", "daemon off;"] ``` **配套的 Nginx 配置文件(`nginx.conf`):** ```nginx # 这个文件和"方式一"的 Nginx 配置几乎一样 # 唯一的区别:root 路径变成了容器内的 /usr/share/nginx/html server { listen 80; server_name _; # _ 表示匹配所有域名(容器内不需要特定域名) root /usr/share/nginx/html; index index.html; # SPA 路由支持(和方式一完全一样,这个必须配!) location / { try_files $uri $uri/ /index.html; } # 静态资源长缓存 location /assets/ { expires 1y; add_header Cache-Control "public, immutable"; } # index.html 不缓存 location = /index.html { add_header Cache-Control "no-cache, no-store, must-revalidate"; } # Gzip 压缩 gzip on; gzip_types text/plain text/css application/json application/javascript text/xml; gzip_min_length 1024; } ``` **构建和运行命令:** ```bash # 构建镜像(在 Dockerfile 所在目录执行) docker build -t myapp-web:v1.0.0 . # 运行容器 docker run -d \ --name myapp-web \ -p 80:80 \ # 把容器的 80 端口映射到宿主机的 80 端口 --restart unless-stopped \ # 容器挂了自动重启 myapp-web:v1.0.0 # 查看日志(确认 Nginx 正常启动) docker logs -f myapp-web ``` --- #### 子方案 B:挂载数据卷(调试/快速迭代用) **场景**:你在调试前端样式,每次改一点都要重新 `docker build`,太慢了! **解决**:把宿主机的 `dist/` 目录"挂载"到容器里,改完代码重新构建后,刷新浏览器就能看到效果,不用重建镜像。 **目录结构规划:** ``` /opt/myapp/frontend/ ← 前端项目根目录 ├── dist/ ← 构建产物(npm run build 生成) ├── nginx.conf ← Nginx 配置文件 └── docker-compose.yml ← 容器编排文件 ``` **docker-compose.yml(关键配置说明):** ```yaml version: '3.8' services: frontend: image: nginx:1.25-alpine container_name: myapp-frontend restart: always # ports:端口映射,格式"宿主机端口:容器端口" # 访问 http://服务器IP:8080 就能看到前端页面 ports: - "8080:80" # volumes(数据卷挂载):这是本方案的核心! # 格式:"宿主机路径:容器路径[:权限]" volumes: # 挂载1:把宿主机 dist/ 挂到 Nginx 的网站目录 # 这样 dist/ 里的文件变了,容器里立刻生效(不用重启容器) - ./dist:/usr/share/nginx/html:ro # ↑ :ro = read-only 只读 # 防止容器内的进程意外修改你的文件 # 挂载2:把宿主机的 nginx.conf 挂进去 # 这样你改了 nginx.conf,执行 nginx -s reload 就能生效 - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro # command:容器启动后执行的命令 # nginx -g "daemon off;" 是前台运行(Docker 容器必须前台运行) command: nginx -g "daemon off;" ``` **完整工作流(大白话版):** ```mermaid graph LR A["你在本地改代码<br/>保存文件"] --> B["npm run build<br/>重新构建"] B --> C["把 dist/ 同步到服务器<br/>scp -r dist/* user@ip:/opt/myapp/frontend/dist/"] C --> D["刷新浏览器<br/>立刻看到最新效果"] D -->|"Nginx 配置要改?"| E["修改服务器上的<br/>/opt/myapp/frontend/nginx.conf"] E --> F["docker exec myapp-frontend<br/>nginx -s reload"] F --> D ``` **关键命令速查:** | 命令 | 大白话解释 | |------|----------| | `docker-compose up -d` | "启动容器",`-d` 是后台运行 | | `docker-compose down` | "停掉并删除容器",卷挂载的数据不会丢 | | `docker exec myapp-frontend nginx -s reload` | "让 Nginx 重新加载配置",修改 nginx.conf 后必执行 | | `docker logs -f myapp-frontend` | "看容器日志",Nginx 报错信息在这里 | --- #### 两种子方案怎么选? ``` 你在做 ↓ 开发调试 / 快速迭代 │ ▼ 用【子方案 B:挂载数据卷】 → 改代码 → build → 刷新浏览器,秒级见效 → 改 Nginx 配置 → docker exec ... nginx -s reload,秒级见效 │ 发版上线 / CI/CD 自动化 │ ▼ 用【子方案 A:打包进镜像】 → docker build 构建镜像 → docker push 推到镜像仓库 → 服务器 docker pull 拉取 → docker run 启动 → 版本可追溯(v1.0.0、v1.0.1...),出问题秒回滚 ``` ### 4.5 环境变量处理技巧 前端项目在**构建时**注入环境变量,而非运行时: ```bash # Vue / Vite 项目:使用 .env 文件 # .env.production VITE_API_BASE_URL=https://api.example.com VITE_APP_TITLE=My App # React / CRA 项目 # .env.production REACT_APP_API_URL=https://api.example.com ``` ```dockerfile # Dockerfile 中注入构建时变量 ARG VITE_API_BASE_URL=https://api.example.com ENV VITE_API_BASE_URL=$VITE_API_BASE_URL RUN npm run build ``` ```bash # 构建时动态指定 docker build --build-arg VITE_API_BASE_URL=https://api.prod.com -t myapp-web . ``` > **注意**:如果需要运行时动态修改 API 地址,可在 `index.html` 中注入 `window.__CONFIG__`,通过 Nginx 的 `sub_filter` 在启动时替换。 --- ## 五、Nginx 反向代理与域名配置  ### 5.1 反向代理:统一入口 将前端 + 后端 API 统一到同一域名下,避免跨域问题: ```nginx # /etc/nginx/conf.d/myapp.conf # HTTP -> HTTPS 重定向 server { listen 80; server_name www.example.com; return 301 https://$host$request_uri; } # HTTPS 主配置 server { listen 443 ssl http2; server_name www.example.com; # SSL 证书(Let's Encrypt 免费获取) ssl_certificate /etc/letsencrypt/live/www.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/www.example.com/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers HIGH:!aNULL:!MD5; # ---- 前端静态文件 ---- location / { root /var/www/myapp; index index.html; try_files $uri $uri/ /index.html; } # ---- 后端 API 代理 ---- location /api/ { proxy_pass http://127.0.0.1:8080/; # Spring Boot proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # WebSocket 支持 proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; # 超时设置 proxy_connect_timeout 60s; proxy_read_timeout 120s; proxy_send_timeout 60s; } # ---- Python API 代理(如有) ---- location /pyapi/ { proxy_pass http://127.0.0.1:8000/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } # 静态资源缓存 location /assets/ { expires 1y; add_header Cache-Control "public, immutable"; } } ``` ### 5.2 SSL 证书配置(Let's Encrypt) ```bash # 安装 Certbot sudo yum install -y certbot python3-certbot-nginx # 获取证书(自动修改 Nginx 配置) sudo certbot --nginx -d www.example.com # 自动续期(Certbot 会自动添加 cron) sudo certbot renew --dry-run ``` --- ## 六、Docker Compose 一键编排  > 当项目包含前端、后端、数据库等多个服务时,用 Docker Compose 统一管理。 ### 6.1 完整的 docker-compose.yml ```yaml # docker-compose.yml version: "3.9" services: # ---- Nginx 网关 ---- nginx: image: nginx:1.25-alpine ports: - "80:80" - "443:443" volumes: - ./nginx/conf.d:/etc/nginx/conf.d - ./nginx/ssl:/etc/letsencrypt - frontend-dist:/usr/share/nginx/html depends_on: - java-api - python-api restart: unless-stopped networks: - app-network # ---- Java Spring Boot 后端 ---- java-api: build: context: ./java-backend dockerfile: Dockerfile environment: - SPRING_PROFILES_ACTIVE=prod - SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/mydb - SPRING_DATASOURCE_USERNAME=${DB_USER} - SPRING_DATASOURCE_PASSWORD=${DB_PASSWORD} - TZ=Asia/Shanghai ports: - "8080:8080" # 仅调试用,生产可去掉 depends_on: db: condition: service_healthy restart: unless-stopped networks: - app-network # ---- Python FastAPI 后端 ---- python-api: build: context: ./python-backend dockerfile: Dockerfile environment: - DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/mydb - TZ=Asia/Shanghai ports: - "8000:8000" # 仅调试用 depends_on: db: condition: service_healthy restart: unless-stopped networks: - app-network # ---- PostgreSQL 数据库 ---- db: image: postgres:16-alpine environment: - POSTGRES_DB=mydb - POSTGRES_USER=${DB_USER} - POSTGRES_PASSWORD=${DB_PASSWORD} - TZ=Asia/Shanghai volumes: - pgdata:/var/lib/postgresql/data # 不暴露端口到公网! # ports: # - "5432:5432" healthcheck: test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"] interval: 10s timeout: 5s retries: 5 restart: unless-stopped networks: - app-network # ---- Redis 缓存 ---- redis: image: redis:7-alpine command: redis-server --requirepass ${REDIS_PASSWORD} volumes: - redisdata:/data restart: unless-stopped networks: - app-network volumes: pgdata: redisdata: frontend-dist: networks: app-network: driver: bridge ``` ### 6.2 环境变量管理 ```bash # .env 文件(不要提交到 Git!) DB_USER=myuser DB_PASSWORD=your_strong_password_here REDIS_PASSWORD=your_redis_password_here ``` ```bash # .gitignore 中添加 .env ``` ### 6.3 一键启停 ```bash # 启动所有服务 docker-compose up -d # 查看运行状态 docker-compose ps # 查看日志 docker-compose logs -f java-api # 重启单个服务 docker-compose restart java-api # 停止并删除容器(数据卷保留) docker-compose down # 重新构建并启动 docker-compose up -d --build ``` --- ## 七、CI/CD 自动化部署  ### 7.1 GitHub Actions 示例 **Java Spring Boot + Docker 自动部署**: ```yaml # .github/workflows/deploy-java.yml name: Deploy Java API on: push: branches: [main] paths: - 'java-backend/**' jobs: deploy: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Set up JDK 17 uses: actions/setup-java@v4 with: java-version: '17' distribution: 'temurin' - name: Build with Maven run: | cd java-backend mvn clean package -DskipTests - name: Build Docker Image run: | cd java-backend docker build -t myapp-java:${{ github.sha }} . - name: Deploy to Server uses: appleboy/ssh-action@v1 with: host: ${{ secrets.SERVER_HOST }} username: ${{ secrets.SERVER_USER }} key: ${{ secrets.SSH_PRIVATE_KEY }} script: | cd /opt/myapp docker pull myregistry/myapp-java:${{ github.sha }} docker-compose up -d java-api docker image prune -f ``` **前端 Vue/React 自动部署**: ```yaml # .github/workflows/deploy-frontend.yml name: Deploy Frontend on: push: branches: [main] paths: - 'frontend/**' jobs: deploy: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: '20' cache: 'npm' cache-dependency-path: frontend/package-lock.json - name: Install & Build run: | cd frontend npm ci npm run build - name: Deploy to Server uses: appleboy/scp-action@v0.1.7 with: host: ${{ secrets.SERVER_HOST }} username: ${{ secrets.SERVER_USER }} key: ${{ secrets.SSH_PRIVATE_KEY }} source: "frontend/dist/*" target: "/var/www/myapp" strip_components: 2 - name: Reload Nginx uses: appleboy/ssh-action@v1 with: host: ${{ secrets.SERVER_HOST }} username: ${{ secrets.SERVER_USER }} key: ${{ secrets.SSH_PRIVATE_KEY }} script: sudo nginx -s reload ``` ### 7.2 GitLab CI 示例 ```yaml # .gitlab-ci.yml stages: - build - deploy variables: DOCKER_IMAGE: registry.example.com/myapp build-java: stage: build image: maven:3.9-eclipse-temurin-17 script: - cd java-backend - mvn clean package -DskipTests - docker build -t $DOCKER_IMAGE/java:$CI_COMMIT_SHA . - docker push $DOCKER_IMAGE/java:$CI_COMMIT_SHA only: changes: - java-backend/**/* deploy: stage: deploy script: - ssh deploy@server "cd /opt/myapp && docker-compose pull && docker-compose up -d" only: - main when: manual # 需手动触发部署 ``` --- ## 八、生产环境最佳实践  ### 8.1 安全清单 - [ ] **不使用 root 运行应用**:Docker 容器内创建专用用户 - [ ] **数据库不暴露公网**:通过安全组/防火墙限制为内网访问 - [ ] **敏感信息使用环境变量**:密码、密钥不硬编码,不提交 Git - [ ] **启用 HTTPS**:Let's Encrypt 免费 SSL - [ ] **设置安全响应头**:X-Frame-Options、CSP、HSTS - [ ] **定期更新依赖**:关注安全漏洞公告 - [ ] **SSH 密钥登录**:禁用密码登录 ### 8.2 日志管理 ```bash # Docker 日志限制 # /etc/docker/daemon.json { "log-driver": "json-file", "log-opts": { "max-size": "50m", "max-file": "3" } } ``` ```bash # Spring Boot 日志配置(application-prod.yml) logging: file: name: /app/logs/application.log logback: rollingpolicy: max-file-size: 50MB max-history: 30 total-size-cap: 1GB ``` ### 8.3 监控告警 | 工具 | 用途 | 推荐场景 | |------|------|----------| | Prometheus + Grafana | 指标采集与可视化 | 中大型项目 | | Uptime Kuma | 站点可用性监控 | 个人/小团队 | | Sentry | 错误追踪 | 所有项目 | | Portainer | Docker 可视化管理 | 容器化项目 | ### 8.4 备份策略 ```bash #!/bin/bash # backup.sh - 数据库定时备份脚本 DATE=$(date +%Y%m%d_%H%M%S) BACKUP_DIR="/opt/backups" # PostgreSQL 备份 docker exec db pg_dump -U myuser mydb | gzip > "$BACKUP_DIR/db_$DATE.sql.gz" # 保留最近 7 天备份 find $BACKUP_DIR -name "*.sql.gz" -mtime +7 -delete echo "Backup completed: db_$DATE.sql.gz" ``` ```bash # 添加 crontab 定时任务(每天凌晨 3 点执行) crontab -e 0 3 * * * /opt/scripts/backup.sh >> /opt/backups/backup.log 2>&1 ``` ### 8.5 性能优化速查 | 项目类型 | 优化点 | 具体措施 | |----------|--------|----------| | Java | JVM 调优 | G1GC、容器感知内存、连接池 | | Python | 并发模型 | Gunicorn 多 worker、异步 IO | | 前端 | 资源优化 | Gzip/Brotli 压缩、CDN、懒加载 | | Nginx | 连接优化 | keepalive、upstream 缓存、限流 | | 数据库 | 查询优化 | 索引、连接池、读写分离 | --- ## 九、常见问题排查 ### 9.1 Java 项目 | 问题 | 原因 | 解决方案 | |------|------|----------| | `OutOfMemoryError` | 堆内存不足 | 调大 `-Xmx`,查看 `logs/heapdump.hprof` 分析内存 | | 启动慢 | 依赖多、扫描路径大 | 开启懒加载 `spring.main.lazy-initialization=true` | | 连接数据库超时 | 网络或连接池配置 | 检查安全组,调整 `spring.datasource.hikari` | | Docker 容器时区不对 | 默认 UTC | 设置 `TZ=Asia/Shanghai` | | 脚本 stop 后进程仍在 | 优雅停机超时 | 检查应用是否注册了 shutdown hook,脚本会在 30 秒后自动 `kill -9` | | 外部配置不生效 | `--spring.config.location` 路径错误 | 确认以 `/` 结尾,如 `../config/`,文件名须为 `application-prod.yml` | | 脚本提示 JAR not found | JAR 文件名与脚本配置不一致 | 检查 `app.sh` 中的 `JAR_NAME` 变量是否与实际文件名匹配 | | 日志目录不存在 | 首次部署未创建目录 | 脚本会自动创建;如手动启动需 `mkdir -p logs tmp` | | `kill -9` 后端口仍占用 | 进程残留 | 等待 1-2 分钟释放,或 `ss -tlnp \| grep :8080` 检查 | | 回滚后配置不匹配 | 备份的 JAR 与现有 config 不兼容 | 同时备份 config 目录,或确保配置向后兼容 | ### 9.2 Python 项目 | 问题 | 原因 | 解决方案 | |------|------|----------| | `ModuleNotFoundError` | 依赖未安装 | 检查 `requirements.txt`,确认 venv 激活 | | 502 Bad Gateway | Gunicorn 挂了 | 查看日志 `docker logs`,增加 worker/超时 | | 静态文件 404 | 路径配置错误 | 检查 `STATIC_URL` 和 Nginx alias | | 并发上不去 | GIL 限制 | 增加 worker 数,考虑异步框架(FastAPI) | ### 9.3 前端项目 | 问题 | 原因 | 解决方案 | |------|------|----------| | 刷新页面 404 | Nginx 未配置 SPA 回退 | 添加 `try_files $uri $uri/ /index.html` | | 接口跨域 | 前后端不同端口/域名 | Nginx 反向代理统一域名 | | 更新后白屏 | 浏览器缓存旧文件 | index.html 设 `no-cache`,静态资源加 hash | | 环境变量不生效 | Vite/CRA 构建时注入 | 检查 `.env.production`,确认 `VITE_`/`REACT_APP_` 前缀 | | Docker 镜像太大 | 未用多阶段构建 | 使用 builder 阶段,最终镜像仅含 Nginx + 静态文件 | ### 9.4 通用排查命令 ```bash # 检查端口占用 ss -tlnp | grep :8080 # 检查 Docker 容器状态 docker ps -a # 查看容器日志(最后 100 行) docker logs --tail 100 -f myapp # 检查 Nginx 配置语法 sudo nginx -t # 测试接口连通性 curl -v http://localhost:8080/actuator/health curl -v http://localhost:8000/health # 查看磁盘空间 df -h # 查看内存使用 free -h # 查看系统负载 uptime ``` --- ## 快速部署速查表 | 技术栈 | 构建命令 | 部署方式 | 默认端口 | |--------|----------|----------|----------| | Spring Boot | `mvn clean package` | 脚本 / Docker Compose / Swarm | 8080 | | FastAPI | - | Gunicorn + Uvicorn / Docker | 8000 | | Flask | - | Gunicorn / Docker | 8000 | | Vue (Vite) | `npm run build` | Nginx / Docker | 80 | | React (Vite/CRA) | `npm run build` | Nginx / Docker | 80 | --- > **写在最后**:部署没有银弹,适合自己的才是最好的。小型项目用 JAR/venv + systemd 就够了,中大型项目上 Docker Compose + CI/CD,微服务架构考虑 Kubernetes。先跑起来,再优化。遇到问题别慌,看日志,查端口,99% 的问题都能定位。
从零构建在线Excel:一个Java全栈工程师的实战记录
# 从零构建在线Excel:一个Java全栈工程师的实战记录 ## 我为什么要自己造这个轮子 说出来你可能不信,起因是公司内部一堆Excel文件满天飞。 财务部的预算表、运营部的数据看板、产品部的需求矩阵——每一次改一个数,就要在微信上重新传一遍文件。文件名从"最终版"进化到"最终最终版"再到"打死也不改了版",像极了程序员给变量起名。 市面上不是没有在线表格产品,腾讯文档、飞书表格都挺好用。但公司内网环境不允许直连外部服务,数据又不能出内网。私有化部署的那些方案,要么贵得离谱,要么定制成本太高。 于是我决定自己动手。一个全栈工程师的自我修养,不就是"没人做我来做"吗? 项目地址:https://github.com/DevYangJC/DataLoom **希望大家给给star,给我增加更新的动力,谢谢大家**   ## 不止是我用:这个项目给你的价值 要说清楚这件事——我不是在做一个"我的项目",我是在做一个"你也能用的零件"。 大多数技术教程里的项目,你照着写完就跑不起来了。要么跟作者的数据库强绑定,要么前端和后端耦合得像连体婴儿,要么硬编码了一堆作者公司才有的配置。说白了,那些代码离开作者的电脑就是个摆设。 DataLoom 从一开始就被设计成**可移植的**。什么意思?就是当你自己的项目需要一个在线表格功能时,你能把这个项目直接复制粘贴进去,改改配置就能跑,不用从零攻坚。 具体来说,我刻意做了这么几件事: **前端是一个独立的 SPA 应用。** 它不依赖特定的后端框架,跟后端只通过 REST API 通信。你后端是 Spring Boot、Go、Node,甚至 Python Flask,都无所谓——把 `excel-web-demo/src` 目录往你项目里一扔,配个 API 代理就行。前端不认后端是谁,只认 `/api/excel` 这个前缀。 **后端是一个独立的微服务。** 它有自己的端口(9191)、自己的数据库(H2),不跟你现有的业务代码搅在一起。你甚至不需要碰它的 Java 代码——启动一个 JAR 包,在线表格能力就有了。等业务量大了想替换掉 H2,改一行 `application.yml` 的数据库配置,MyBatis-Plus 自动适配,SQL 一行不用改。 **数据模型解耦干净。** 文档、Sheet、数据块三张表之间只用外键关联,不写存储过程、不依赖触发器、不用数据库专有特性。换成任何关系型数据库都只要建三张表,改改连接串就行。 **启动链路就是一个 shell 脚本的事。** 后端 `mvn spring-boot:run`,前端 `npm run dev`。如果打包成 Docker,就是一个 `docker-compose.yml`,两行 `services` 搞定。你的团队成员 clone 下来,两分钟之内就能在本地跑到"上传 → 编辑 → 导出"的完整闭环。 说白了,**DataLoom 不是你照着学的玩具项目,是你抄来就用的功能模块。** 你项目的管理后台缺一个数据看板?把一个 Excel 当模板上传进去,线上改数据、导出报表,直接搞定。你自己的 SaaS 产品需要给客户提供表格编辑能力?把这两个目录放进你的微服务集群,注册个域名,齐活。 后面讲的技术细节——分块存储、脏数据追踪、前端导出——都是在保证"能用"的前提下,做到"好用"。但"能用"这件事,在架构设计阶段就已经写死进去了。 ## 技术选型:少即是多 需求其实很明确:上传Excel → 在线编辑 → 导出Excel。听起来简单,但细节全是坑。 **前端**,Vue 3 + Vite + Element Plus。Vue 3的Composition API是这次选型里我最满意的决定——`<script setup>`语法写起来比Options API清爽太多了,逻辑拆成独立函数,不用再在`data`、`methods`、`computed`之间来回跳。Vite替代Webpack,冷启动和热更新都快了一个量级,改一行代码瞬间看到效果,开发体验上了不止一个台阶。 核心的表格渲染引擎,我锁定了[Luckysheet](https://github.com/dream-num/Luckysheet)。这是国内团队做的一个开源在线电子表格,长得跟Excel几乎一模一样。说实话,看到它的demo那天我差点没睡着——这不就是我要的东西吗?支持单元格编辑、公式计算、合并单元格、条件格式,甚至连图表都有。后来这个项目被字节跳动收购了,演变成了现在的Univer,但早期开源版本依然很好用。 **后端**,Spring Boot 2.1 + MyBatis-Plus,老牌组合,稳得一批。Java 8,不用解释。 关键的选择在**Excel解析和导出**这两块。解析选了Apache POI,老牌选手,虽然API丑得像上个世纪的东西,但胜在稳定,Excel 97到2007+通吃。导出我走了另一条路——前端用ExcelJS,后端用EasyExcel做补充。为什么?因为Luckysheet的数据在前端,前端的ExcelJS可以直接拿到每个单元格的样式信息(字体、颜色、边框),导出的效果几乎100%还原。如果走后端导出,光是把样式信息传到后端就够喝一壶的。 **数据库**,Demo阶段直接用H2嵌入式数据库,零配置启动。等你把代码clone下来,`mvn spring-boot:run`一行命令就能跑起来,MySQL都不用装。 ### 一图看全貌:现在的架构长什么样 ```mermaid flowchart TB subgraph 用户["👤 用户浏览器"] Upload["📤 上传 Excel 文件"] Edit["✏️ 在线编辑单元格"] Export["📥 导出 Excel"] end subgraph Frontend["🎨 Vue 3 + Vite 前端 (8080)"] Luckysheet["Luckysheet 表格引擎"] DirtyTracker["脏数据追踪 reactive{}"] ExcelJS["ExcelJS 导出引擎"] Router["Vue Router 路由"] end subgraph Backend["⚙️ Spring Boot 后端 (9191)"] Controller["ExcelFileController<br/>REST API"] Service["ExcelFileService<br/>业务逻辑"] Parser["POI 解析器<br/>WorkbookFactory"] ChunkStore["分块存储引擎<br/>每1000行一块"] end subgraph Storage["💾 数据层"] H2[("H2 嵌入式数据库<br/>excel_file / excel_sheet / chunk_data")] end Upload -->|"POST /api/excel/upload"| Controller Controller --> Parser Parser -->|"按Sheet逐行解析"| ChunkStore ChunkStore -->|"分批写入"| H2 Edit -->|"cellUpdated 事件"| Luckysheet Luckysheet -->|"markCellDirty()"| DirtyTracker DirtyTracker -->|"用户点保存<br/>POST /api/excel/batchUpdate"| Controller Controller --> Service Service -->|"定位 Chunk → 局部更新"| H2 Export -->|"用户点导出"| ExcelJS ExcelJS -->|"直接读 Luckysheet 数据"| Luckysheet Luckysheet -->|"GET /api/excel/{id}/data<br/>按块懒加载"| Controller Controller --> H2 Router -->|"文档列表"| Controller Controller -->|"GET /api/excel/list"| H2 style 用户 fill:#534AB7,color:#fff style Frontend fill:#4A90D9,color:#fff style Backend fill:#5CC9C1,color:#fff style Storage fill:#2C3E50,color:#fff ``` 这张图基本上就是 DataLoom 的全貌了。三个核心闭环: - **上传流**(紫色→蓝色→绿色→灰):文件从前端飞进后端,POI 拆解成 Luckysheet 格式,按 1000 行一块塞进 H2 - **编辑流**(蓝色闭环):Luckysheet 捕获每次编辑 → 脏数据追踪暂存 → 批量保存到后端 - **导出流**(蓝色闭环):ExcelJS 直接读前端 Luckysheet 数据生成 Blob 下载,不走网络 注意导出那条线——它根本不走后端。这就是为什么样式能做到 100% 还原的原因。后面会展开讲。 ## 分块存储:这个设计救了我的命 很多人做在线表格,第一反应就是把整个Excel的JSON存到数据库的一个字段里。50行的表格这么干没问题。5000行,勉强能撑。5万行呢?10万行呢? 一个10万行、20列的表格转成JSON大概有几十MB。把几十MB的JSON塞进数据库的一个字段里,MySQL直接给你脸色看——超长字段、查询慢、更新要全量替换,每一条都是死路。 我的方案是**分块存储**。每1000行切一块。用图说话: ```mermaid flowchart LR subgraph Excel["📊 原始 Excel 文件<br/>10万行 × 20列"] R0["行 0 ~ 999"] R1["行 1000 ~ 1999"] R2["行 2000 ~ 2999"] RD["..."] R99["行 99000 ~ 99999"] end subgraph DB["💾 excel_chunk 表"] C0[("Chunk #0<br/>chunk_index=0<br/>celldata_json<br/>~500KB")] C1[("Chunk #1<br/>chunk_index=1<br/>celldata_json<br/>~500KB")] C2[("Chunk #2<br/>chunk_index=2<br/>celldata_json<br/>~500KB")] CD["..."] C99[("Chunk #99<br/>chunk_index=99<br/>celldata_json<br/>~500KB")] end subgraph Edit["✏️ 编辑第 8888 行 C 列"] Locate["📍 8888 ÷ 1000 = Chunk #8"] Update["🔧 只更新 Chunk #8<br/>其余 99 块纹丝不动"] end R0 -->|"POI 解析"| C0 R1 -->|"POI 解析"| C1 R2 -->|"POI 解析"| C2 RD -->|"POI 解析"| CD R99 -->|"POI 解析"| C99 C8["Chunk #8"] -.->|"定位目标"| Locate Locate --> Update style Excel fill:#534AB7,color:#fff style DB fill:#2C3E50,color:#fff style Edit fill:#E74C3C,color:#fff ``` 传统做法是把整个 Excel 的 JSON 塞进数据库一个字段里。50 行的表格这么干没问题。5000 行勉强能撑。5 万行、10 万行?几十 MB 的 JSON 塞进去,查询慢、更新要全量替换,每一条都是死路。 分块之后,每个块只存 1000 行的单元格数据,JSON 大小控制在 500KB 左右。用户编辑第 8888 行的 C 列——8888 除以 1000 等于第 8 号块,API 只在这个块里找到那一行那一列,改了就完事。不用动其他 99 个块,IO 消耗约等于零。 这个设计还有一个额外好处:**懒加载**。打开一个 10 万行的表格,前端不用一次性把所有数据都拉下来。先加载文档的元信息(有哪些 Sheet、每个 Sheet 多少行),用户切换到某个 Sheet 时,再按块范围按需请求数据。滚动到哪加载到哪,体验跟本地 Excel 几乎没区别。 如果你也在做类似的东西,我建议别等到数据撑爆了再重构。分块存储这个决策,在架构阶段就定下来,后期改的成本太高了。 ## 上传解析:POI 的正确打开方式 上传 Excel 文件的流程,用一张时序图比文字直观得多: ```mermaid sequenceDiagram actor U as 👤 用户 participant FE as 🎨 Vue 前端 participant Ctrl as 🔧 ExcelFileController participant Svc as 📦 ExcelFileService participant POI as 📑 Apache POI participant DB as 💾 H2 数据库 U->>FE: 选择 Excel 文件 FE->>FE: el-upload 组件封装 FormData FE->>Ctrl: POST /api/excel/upload<br/>multipart/form-data Ctrl->>Ctrl: 生成 UUID 文件名<br/>保存到磁盘临时目录 Ctrl->>Svc: parseAndStore(filePath, uuidName) Svc->>POI: WorkbookFactory.create(inputStream) POI-->>Svc: Workbook 对象 Svc->>DB: INSERT excel_file 记录 loop 遍历每个 Sheet Svc->>DB: INSERT excel_sheet 记录 loop 每 1000 行一个 Chunk Svc->>POI: 逐行逐列读取单元格 POI-->>Svc: 原始 Cell 值 alt 日期类型 Svc->>Svc: DateUtil.isCellDateFormatted() ✅ else 公式类型 Svc->>POI: FormulaEvaluator.evaluate() POI-->>Svc: 计算结果值 else 数字/字符串 Svc->>Svc: 直接转换 end Svc->>Svc: 转成 Luckysheet celldata 格式 Svc->>DB: INSERT excel_chunk (celldata_json) end end Svc-->>Ctrl: 解析完成 Ctrl-->>FE: 200 OK + excelFileId FE->>FE: 跳转到编辑页 FE-->>U: 看到在线表格 🎉 ``` 1. 前端用 `<el-upload>` 组件把文件发到后端 2. 后端保存到磁盘,生成 UUID 文件名避免冲突 3. 用 Apache POI 的 `WorkbookFactory` 打开文件流 4. 遍历每个 Sheet → 遍历每一行 → 遍历每个单元格 5. 把单元格值转成 Luckysheet 能认的格式,按 1000 行分批写入数据库 第 5 步是重点。POI 解析出来的单元格类型五花八门——字符串、数字、日期、公式、布尔值、空白、错误。每种类型都要转换成 Luckysheet 的数据格式: ```json { "r": 0, "c": 1, "v": { "v": 8848, "m": "8848", "ct": { "fa": "General", "t": "n" } } } ``` 数字类型和日期类型是最容易搞混的。Excel里日期其实就是一个数字(从1900-01-01开始的天数),POI需要用`DateUtil.isCellDateFormatted()`来判断。我一开始没注意到这个细节,所有日期都变成了五位数,看着像身份证号,排查了半小时才发现是漏了日期检测。 公式的处理稍微复杂一点。POI读公式单元格时,`getCellType()`返回的是`FORMULA`类型,你需要额外用`FormulaEvaluator`去计算结果值。如果公式引用了其他Sheet的数据,`FormulaEvaluator`也可能算不出来(因为解析时可能还没加载到那个Sheet),这种情况就给个空值,让Luckysheet在前端自己算。 ## 在线编辑:脏数据追踪 Luckysheet 提供了非常完善的事件钩子。单元格内容变化有 `cellUpdated`,工具栏操作(改字体、加粗、合并单元格)有 `updated`。 但问题来了:Luckysheet 的 `cellUpdated` 只告诉你"第 3 行第 5 列变了",不会自动帮你把变化存到后端。你需要自己维护一个"脏数据"集合。 整个编辑→保存的生命周期,看这张图: ```mermaid stateDiagram-v2 [*] --> 加载完成: Luckysheet 初始化 加载完成 --> 等待编辑: 数据渲染完毕 等待编辑 --> 有脏数据: cellUpdated / updated 触发<br/>markCellDirty() 有脏数据 --> 等待编辑: 继续编辑<br/>累积更多脏数据 有脏数据 --> 保存中: 用户点击"保存"<br/>saveChanges() 保存中 --> 等待编辑: 保存成功 ✨<br/>清空 dirtyCells + ElMessage 保存中 --> 有脏数据: 保存失败 ❌<br/>保留脏数据,用户可重试 等待编辑 --> 危险操作: 用户切换 Sheet / 关闭页面 危险操作 --> 确认离开: hasUnsavedChanges = true<br/>弹窗"有未保存的修改" 确认离开 --> [*]: 用户确认放弃 确认离开 --> 保存中: 用户选择"先保存再离开" ``` 得益于 Vue 3 的 Composition API,我把脏数据追踪逻辑拆成了几个独立的函数,每个只做一件事,不用在一个巨大的组件对象里翻来翻去。核心是用 `reactive` 管理脏数据集合,key 是 `sheetId_row_col`: ```javascript // SheetEditor.vue <script setup> import { reactive } from 'vue' const dirtyCells = reactive({}) const hasUnsavedChanges = computed(() => Object.keys(dirtyCells).length > 0) function markCellDirty(r, c, newValue) { const currentSheet = window.luckysheet?.getSheet?.() if (!currentSheet) return const dbSheetId = sheetIdMap[currentSheet.index] dirtyCells[`${dbSheetId}_${r}_${c}`] = { sheetId: dbSheetId, r, c, v: newValue } } async function saveChanges() { const updates = Object.values(dirtyCells) if (updates.length === 0) { ElMessage.info('没有需要保存的修改') return } saving.value = true try { await batchUpdateCells(documentId.value, updates) Object.keys(dirtyCells).forEach(key => delete dirtyCells[key]) ElMessage.success(`保存成功,共 ${updates.length} 个单元格`) } finally { saving.value = false } } ``` 每次编辑触发`markCellDirty`往`dirtyCells`里塞数据,用户点保存时打包批量请求。 这里有一个坑:`updated`事件不会告诉你具体改了哪个单元格。它只是在工具栏操作(比如点击"加粗")后触发,你需要自己去拿当前选中的区域。我是通过`markCurrentSelectionDirty()`方法,先调用`luckysheet.getRange()`获取选中范围,然后把选中区域里所有有值的单元格都标记为脏数据。这个方法有点暴力,但胜在不会漏。 另外,Vue 3的`onBeforeUnmount`里我做了件以前容易忘的事——注销Luckysheet实例和手动绑定的DOM事件。`<script setup>`里没有`beforeDestroy`钩子了,但`onBeforeUnmount`语义更清晰,而且可以写多个,互不干扰。 ## 导出:为什么我把这件事交给了前端 说到 Excel 导出,你可能会想:后端有 EasyExcel,干嘛不用? 简单说:样式的锅。用图对比一下两种方案就清楚了: ```mermaid flowchart LR subgraph Backend["❌ 后端导出方案"] direction TB B1["Luckysheet 数据<br/>含样式信息"] -->|"序列化 + 网络传输<br/>几十 MB"| B2["后端接收"] B2 -->|"Apache POI / EasyExcel<br/>逐个单元格重建样式"| B3["生成 .xlsx 文件"] B3 -->|"再次网络传输"| B4["浏览器下载"] B5["⚠️ 样式信息传输成本高<br/>⚠️ 后端需完整重建样式引擎"] end subgraph Frontend["✅ 前端导出方案 (DataLoom 采用的)"] direction TB F1["Luckysheet 数据<br/>已在浏览器内存中"] -->|"零网络传输<br/>内存直接操作"| F2["ExcelJS 构建 Workbook"] F2 -->|"样式天然兼容<br/>无需转换"| F3["生成 Blob"] F3 -->|"FileSaver 触发下载"| F4["浏览器下载"] F5["✨ 网络开销:0<br/>✨ 样式还原:100%<br/>✨ 导出速度:秒级"] end style Backend fill:#E74C3C,color:#fff style Frontend fill:#27AE60,color:#fff ``` 后端用 EasyExcel 导出,你需要把每一个单元格的字体、字号、颜色、背景色、边框样式、对齐方式、数字格式——全都在后端重建一遍。这些信息都在前端的 Luckysheet 数据里,传到后端要经过序列化、网络传输、反序列化。一个 10 万行的表格,光样式数据的传输就能让人等到下班。 而前端的 ExcelJS 可以直接读取 Luckysheet 的数据结构(它俩的格式高度兼容),在前端内存里构建 Workbook 对象,然后输出为 Blob,通过 FileSaver 触发下载。全程不走网络,样式零丢失。 唯一的代价是:导出大文件时,浏览器可能会短暂卡顿。解决方法也简单——加个 loading 动画,告诉用户"正在生成文件,请稍候"。心理体验比硬等要好得多。 ## 四个 Phase:从"能用"到"好用"的路线图 先上一张全局路线图,看清楚每个阶段在做什么、依赖关系是什么: ```mermaid gantt title DataLoom 演进路线图 dateFormat YYYY-MM axisFormat %Y-%m section Phase 1 基础能力 ✅ 文件上传解析 :done, p1a, 2025-01, 2025-02 Luckysheet 集成 :done, p1b, 2025-02, 2025-03 分块存储 + 懒加载 :done, p1c, 2025-03, 2025-04 脏数据追踪 + 手动保存 :done, p1d, 2025-04, 2025-05 前端 ExcelJS 导出 :done, p1e, 2025-05, 2025-06 section Phase 2 实时协作 ⬜ WebSocket 服务搭建 :p2a, 2025-07, 2025-08 LWW 冲突解决 :p2b, after p2a, 2025-09 在线用户列表展示 :p2c, after p2a, 2025-09 编辑广播同步 :p2d, after p2b, 2025-10 section Phase 3 增强完善 ⬜ 操作日志系统 :p3a, 2025-10, 2025-11 撤销重做 :p3b, after p3a, 2025-11 分享链接生成 :p3c, 2025-10, 2025-11 三档权限控制 :p3d, 2025-11, 2025-12 定时快照 :p3e, after p3a, 2025-12 section Phase 4 性能优化 ⬜ POI 流式读写 SXSSF :p4a, 2026-01, 2026-02 Redis 分片缓存 :p4b, after p4a, 2026-02 并发压测 + 索引优化 :p4c, after p4b, 2026-03 ``` 目前的 V1 已经跑通了**文件上传解析 → 在线编辑 → 手动保存 → 前端导出**这个最基础的闭环。一个人用没问题,但离真正意义上的协作工具还很远。后面分四个阶段来补齐。 ### Phase 1(当前 · ✅ 已完成) 这就是你现在看到的版本——上传、解析、编辑、保存、导出。基础能力拉满,但本质上是单机版的体验搬到浏览器里了。适用于"一个人改一个表"的场景。 ### Phase 2:实时协作 协同编辑是整个项目里最难啃的骨头。核心思路是给 `excel-service-demo` 扩展一个独立的 `socket-service`,新增 `ExcelWebSocketServer`,让前端通过 WebSocket 把每次编辑动作实时广播出去。 冲突解决这块,不急着上 OT 或 CRDT 那种重型方案——先用 **LWW(Last Writer Wins,最后写入者获胜)**。说白了就是"谁后改谁说了算",实现简单,覆盖 90% 的协同场景。同一个单元格两个人同时改了不同的值,服务器按时间戳取最新的那个写进去。 配套要做的:在线用户列表。文档页展示当前有哪些人在编辑这个表,头像 + 名字,谁在线一目了然。这个不只是好看——用户知道有别人在改同一个文档,自然会避免冲突。 ### Phase 3:增强完善 这时候表已经能多人协作了,得把"可靠性"和"可分享性"补上: - **操作日志**:谁在什么时候改了哪个单元格,从什么值改成什么值。一条不多地记录。出了数据事故能追责,改错了能回滚。 - **撤销重做**:现在的浏览器刷新就没了,操作日志是撤销重做的数据源。跨会话、跨设备都能回到历史状态。 - **定时快照**:每隔一段时间自动保存一份全量的表格快照。服务器崩了不至于回到解放前。 - **分享链接**:生成一个链接发给同事,点开就能看/编辑。不只是"上传文件"这一种入口,分享才是表格流转的核心路径。 - **权限控制**:三档角色——OWNER(拥有者,能删能改权限)、EDITOR(编辑者,能改数据不能删文档)、VIEWER(查看者,只能看不能动)。权限不写在代码里,存数据库,前端按角色动态渲染按钮。 ### Phase 4:性能优化 功能全了,该修路了。 - **大文件优化**:现在上传用的 POI `WorkbookFactory` 是全部读到内存再解析的。换用 `SXSSF`(流式写入)和 `XSSFReader`(流式读取),100MB+ 的 Excel 也不会 OOM。 - **Redis 分片存储**:H2 换成 MySQL 之后,大 JSON 块不能继续这么存。用 Redis 做热数据缓存,大 JSON 按 1000 行分片存进 String 类型的 key,查哪个块读哪个块,比扫数据库快一个数量级。 - **并发压测**:用 JMeter 或 wrk 模拟 50、100、200 个并发用户同时编辑,找瓶颈、加索引、调连接池,最终给出一个"建议最大并发数"。 ### 做完四个 Phase 之后 V1 是一个人能用,Phase 4 跑完之后是一个团队能依赖。从"玩具"到"工具",差的就是这四个阶段里的工程化细节。每一步都有坑,但我已经隐约看到它们在哪了——剩下的就是时间问题。 ## 总结 从零构建一个在线Excel,难点不在"能不能做出来",而在于"数据大了怎么办"和"多个用户一起改怎么办"。分块存储解决了前者,协同编辑还在解决后者的路上。 如果你也想做类似的东西,我的建议是:先跑通最小闭环,再逐层加功能。上传→解析→显示→编辑→导出,这五个节点打通了,你手里就是一个能用的产品。后面的分块优化、协同编辑、UI打磨,都是在"能用"的基础上做到"好用"。 源码我放在GitHub上了,感兴趣的朋友可以去看看。里面包括完整的后端服务和前端页面,clone下来改改配置就能跑。 --- 这就是"从零构建在线Excel"的过程。不是PPT架构师讲的那种"我们只需要三步"的爽文路线,而是一个真实的Java全栈工程师,在各种细节和坑之间来回试探的过程。但说实话,当你看到自己写的系统成功打开一个10万行的Excel,并且在浏览器里流畅地编辑时——那种感觉,值了。
vue 组件的使用与指令关系
我有一个员工tip组件,我的实现是 <UserTip :userid="12345"> <span>张三</span> </UserTip> 如果我用指令封装下 v-userid='12345' 调取下组件 这种方式合规吗 有啥歧义?
重生之我在地球Online学JAVA day02
1、JS引入方式 内部脚本:将JS代码定义在html页面的< script>< /script>中; 建议:将< script>< /script>放在< body>的底部; 外部脚本:将JS代码定义在js文件中,通过< script>< /script>标签引入; 注意:通过< script>标签引入外部js文件时,标签不可以自闭合。 2、JS变量 (1)特点:JS是弱类型语言,变量可以存放不同类型的值。 (2)声明:var:声明变量,全局作用域/函数作用域,**允许重复声明**; let:声明变量,块级作用域,**不允许重复声明**; const:声明常量,一旦声明,**常量的值不能改变**。 3、运算符(注意):**==** 会进行类型转换,**===** 不会进行类型转换 4、**函数定义方式**(2中) (1)var functionName = function (参数1,参数2..){//要执行的代码} (2)function functionName(参数1,参数2..){//要执行的代码} 5、JS对象: (1)Array数组(注意):JavaScript 中的数组相当于 Java 中集合,数组的长度是可变的,而 JavaScript 是弱类型,所以可以存储任意的类型的数据。 箭头函数(ES6):是用来简化函数定义语法的。具体形式为: (…) => { … } ,如果需要给箭头函数起名字: var xxx = (…) => { … } (2)**JS自定义对象格式**: *var 对象名 = { 属性名1: 属性值1, 属性名2: 属性值2, 属性名3: 属性值3, 函数名称: function(形参列表){} };* (3)JSON介绍 概念:JavaScript Object Notation,JavaScript对象标记法。 JSON 是通过 JavaScript 对象标记法书写的文本。 **定义格式:** var 变量名 = '{"key1": value1, "key2": value2}'; (4)BOM对象 概念:Browser Object Model(浏览器对象模型),允许JavaScript与浏览器对话, JavaScript 将浏览器的各个组成部分封装为对象。 1)Window:浏览器窗口对象; 2)Navigator:浏览器对象; 3)Screen:屏幕对象; 4)History:历史记录对象; 5)Location:地址栏对象; (5)DOM对象 概念:Document Object Model (文档对象模型)。 将标记语言的各个组成部分封装为对应的对象: 1)Document:整个文档对象 2)Element:元素对象 3)Attribute:属性对象 4)Text:文本对象 5)Comment:注释对象 6)HTML中的Element对象可以通过Document对象获取,而Document对象是通过window对象获取的。 Document对象中提供了以下获取Element元素对象的函数: 根据id属性值获取,返回单个Element对象 **var h1 = document.getElementById('h1');** 根据标签名称获取,返回Element对象数组 **var divs = document.getElementsByTagName('div');** 根据name属性值获取,返回Element对象数组 **var hobbys = document.getElementsByName('hobby');** 根据class属性值获取,返回Element对象数组 **var clss = document.getElementsByClassName('cls');**
小厂全栈实习面经:双非大三首次面试(笔试手写JWT)
## 个人背景 - **学历**:双非本科大三 - **学习经历**:学习过黑马苍穹外卖和天机学堂 - **实习经历**:无实习经历 ## 背景与笔试 ### 整体流程 投递简历后收到面试邀请,面试流程分为笔试和面试两个环节。笔试环节采用手写代码的形式,考察全栈开发能力。 ### 笔试内容 - **后端部分**:要求手写 `User.java` 的 PO 实体类、登录 DTO 数据传输对象,以及 JWT 校验工具类,并实现登录接口和校验逻辑 > JWT 工具类未完全实现,登录校验逻辑做了基础实现 - **前端部分**:要求手写 `login.vue` 登录页面和路由工具 > 因前端经验有限,仅说明会使用 ElementUI、AntDesign 等组件库 ### 笔试感受 小厂全栈岗位的笔试题覆盖前后端技术栈,考察范围较广,但是手写jwt。。。不清楚何意味 --- ## 面试 笔试之后就是面试,面试就是简单聊了聊,没问太多,最主要就是问稳定性和能不能来,薪资能不能接受,加班能不能接受。 **前端会不会做** >后端做的多一些,前端也能做,简历上也是后端多一些,会用一些ElementUI,AntDesign这类组件库 **vue有没有接触过,会有写后台管理相关的事,前端也写过对吧,我们就尽量不再写接口再对了,太麻烦了** >接触过,后台管理也写过,前端也都写过,就一人全栈全干了对吧 **对的,能干吗** >没问题 **你在你之前的公司是干啥的** > 刚开始做运维系统,主要是打杂,后续参加了公司保险项目的订单模块,参与一些业务逻辑,写了一些增删改查 **能跟着上手干活吗** > 还行,前面有些跟不上,后面慢慢就跟上了,当时那家公司的文档比较多 **反正就是说白了,如果你过来的情况下就直接能上手了是吧** > 对的没问题没问题 **是长期的哈,能接受长期实习** > 对,可以接受 **长期实习的话就是说毕业以后也可以留在公司对吧** > emm...合适的话会留的 **彳亍,没完成工作加班什么的可以吗** > 加班大概到什么程度呢 **就是我们给你制定工作,你在合理范围内合理时间完成,那你得自觉加班** > 没问题,完不成那不就是我的问题吗 **我简单问几个问题,我们这边也算是国家合作的实训基地,也是定期会找实习生,以前找的实习生总是摸鱼,能长期干的实习出去也都是特别厉害的那种,能正常融入工作节奏就行** > 您放心,我肯定不摸鱼,我在之前公司工作的都老认真了,这肯定是没问题的 **springboot问你几个常用的注解吧,springboot你用的比较多还是springcloud用的比较多** > springboot比较多,因为controller还是用boot **小程序有接触过吗** > 接触过,像有些地方要用到appid之类的 **springboot常用注解简单说说,就是有印象的** > 像启动类上的application注解,还有的像是自动注入的,这些是mvc的 **内个内个像maven内个git什么的都会用吗,idea用的熟吗** > 嗯会用,idea直接集成git就能用了 **数据库mysql是吧,国产数据库有没有接触过** > 嗯。。。这个还真没有,数据库用的多的就是mysql,MongoDB这类的 **数据库我再问你,比如说几个聚合函数吧:求和,分页,分类求和,左右连接,有什么区别** > sum、limit、group by,left join、right join,左连接就是能查到左面那个表的详细信息,右连接。。。 **Kafka接触过哈** > 用过,但是用的不太多 **nginx的轮询配置文件里调过吗** > 这个没有调过,因为我们那个项目是前后端分离的哈 **nignx 负载均衡调过吗** > 了解过,默认是轮询 **docker会用哈,Linux简单命令都清楚哈** > 会用,没问题 **Linux删目录、删进程** > rm -rf、kill **K8S接触过吗** > 这个也是了解过,但是平时不怎么用 **ai工具你用过哪些** > 像cursor、trae、Qwen、Gemini这些主流ai都用过 **你拿cursor能写代码吗** > 嗯。。。可以,平时用trae比较多,因为trae便宜 **哦这个我不知道,你在你们那专业课排多少,百分之多少** > 每年那个奖学金没问题,百分之二三十吧 **专业有多少人** > 360 --- 后续就是说了一些公司相关的业务,聊了聊方向,问了多长时间上岗,还问了是不是为了混三方协议来的,感觉聊的还可以,聊了聊投简历面试多不多,行情怎么样之类的 --- ## 总结 面试体验方面,流程相对简单,主要围绕稳定性、入职意愿和基本技能展开,技术深度提问较少,更看重稳定性、工作态度和上手能力,而非技术细节;岗位分析来看,是典型的小厂全栈岗位,需要一人负责前后端开发,包括后台管理系统的前端工作,加班情况则需要根据工作任务自主安排时间完成工作目标; ### 后续进展 面试后一天收到通过通知,拒了。面小厂全栈主要就是积累面试经验,虽然八股项目都没问,也没有积累多少。 (**实在是不太明白这个笔试有什么意义,之前完全没有准备过,有懂行的大佬可以说说**)
vue ref和reactive的区别
下面我把 ref 和 reactive 在 Vue 3(Composition API)中的差异、原理、常见用法与坑都讲清楚——带示例、对比和实战建议,方便你直接拿去用。 核心结论(先看要点) - ref:用来包裹 任意值(常用于原始值 Number/String/Boolean),返回 { value } 的响应式容器。对对象也可以用 ref(obj),但它是“以 ref 的形式”持有该对象。模板里会自动解 .value。 - reactive:接收一个 对象/数组/Map/Set,返回该对象的 Proxy(深度响应式),直接使用属性访问,无需 .value。 - 选用建议:简单原始值用 ref;结构化对象/集合用 reactive。需要在 Composition API 中解构响应式对象时,使用 toRefs / toRef 保持响应性。 --- 原理简述(简单版) - Vue 的响应式基于 Proxy。reactive(obj) 返回对 obj 的代理(proxy),拦截读取/写入来收集依赖并触发更新(深层追踪)。 - ref(x) 返回一个对象 { value: reactiveWrappedValue }。当 value 被读取/写入时,会进行依赖追踪与触发。对于对象 ref(obj),内部会对对象进行响应式处理(默认深度),但它的外层仍是一个 ref 容器。 --- 代码对比(最小示例) ```js import { ref, reactive } from 'vue' // ref 用法(原始值) const count = ref(0) count.value++ // 在 JS 里要用 .value // 在模板中: {{ count }} // 模板会自动解包,不需要 .value // reactive 用法(对象) const state = reactive({ a: 1, b: { c: 2 } }) state.a = 2 // 直接更新属性,响应式生效 // 深层属性 state.b.c 也是响应式的 ``` --- 深入对比:行为与细节 1. 支持的类型 - reactive:设计用于对象 ({}/[]/Map/Set)。对非对象使用并不合适(通常直接使用 ref)。 - ref:任意类型(原始值、对象、数组 等)。 2. 访问方式 - ref:在 JS 中必须 .value(模板自动解包)。 - reactive:直接 state.prop。 3. 深度(是否递归) - reactive:深度响应式(内部所有嵌套对象都会被代理)。 - ref(obj):外层是 ref,但 .value 的对象也会被转换为响应式(深度),除非使用 shallowRef。 4. 解构丢失响应性(常见坑) const state = reactive({ a: 1, b: 2 }) const { a } = state // 解构得到的是普通变量,不会响应 // 正确做法: import { toRefs } from 'vue' const { a } = toRefs(state) // a 是 ref,保持响应性 对 ref 解构通常不是问题,因为你通常不会解构 .value。 5. 判断与转换工具 - isRef(x)、isReactive(x):判断。 - unref(x):如果是 ref 则返回 .value,否则返回 x。 - toRef(obj, 'key') / toRefs(obj):把对象属性转成 ref 便于解构。 --- 与模板的交互 - 模板会自动 unwrap ref(所以模板中写 {{ count }} 即可)。 - reactive 在模板中直接使用属性:{{ state.a }}。 --- 与 watch / computed 的配合 - watch 可以监听 ref 或 reactive(监听整个对象需要传 getter 或 deep: true)。 - computed 接受函数或 getter,返回 Readonly ref。 示例: watch(() => state.a, (newVal) => { ... }) // 监听 reactive 的某个字段 watch(count, (v) => { ... }) // 监听 ref --- 特殊 API:shallowRef / shallowReactive / customRef - shallowRef:只对 .value 做响应,不会对内部对象做深度转换(适合大对象或第三方不可代理对象)。 - shallowReactive:只对第一层做响应,内部嵌套对象不再递归代理。 - customRef:自定义响应行为(节流、防抖等场景)。 --- 与 Object.assign / 解构 / 扩展运算符 的关系(你之前的问题) Object.assign({}, obj1, obj2) 或 { ...obj1 } 会创建一个 新普通对象,拷贝的是 当前值,不会保留原来的 Proxy/响应式连接。举例: ```js const a = reactive({ x: 1 }) const merged = Object.assign({}, a) // merged 不是响应式 const spreaded = { ...a } // 也不是响应式 ``` // 如果想得到响应式对象: ```js const mergedReactive = reactive(Object.assign({}, a)) ``` 如果你想把 reactive 对象的属性合并到另一个 reactive 上,直接修改属性会保留响应性: ```js Object.assign(state, other) // 如果 state 和 other 已是 reactive,直接修改 state 的属性会触发响应 ``` --- 常见误区 & 实战建议 - 原始值优先用 ref(比如 count, isOpen)。 1. 复杂状态对象/集合用 reactive(例如 user = {profile, settings}、数组数据 list)。 2. 解构 reactive 时丢失响应性:用 toRefs() 或在模板/函数中直接访问 state.prop。 3. 想替换整个对象:如果你需要替换整个 state(例如 state = newObj),reactive 不能被重新赋值(通常 const state = reactive(...)),你应把需要替换的字段逐一更新,或用 ref 持有对象然后 objRef.value = newObj(这样替换会生效)。 4. 性能考虑:对大对象或第三方对象使用 shallowRef/shallowReactive,避免不必要的深度代理开销。 5. 模板里不用 .value,但在 JS 里要注意区分(或使用 unref / toRefs 帮助转换)。 --- 常见场景举例 场景 A:计数器 ```js const count = ref(0) count.value++ ``` 场景 B:表单对象 ```js const form = reactive({ name: '', age: 0 }) // 方便:直接 form.name = 'x' ``` 场景 C:需要替换整个对象(例如从接口拿到新对象) ```js const userRef = ref({}) // 用 ref 包裹对象,方便整体替换 userRef.value = await fetchUser() ``` 或者: // 如果用 reactive: ```js Object.assign(user, await fetchUser()) // 更新属性而不是替换引用 ``` 场景 D:组合使用(推荐) ```js const state = reactive({ items: [] }) const loading = ref(false) // 清晰:state 用于结构化数据,loading 用于原始布尔 ``` --- 快速 API 备忘(常用函数) - ref(value), reactive(obj), readonly(x) - isRef(x), isReactive(x), unref(x) - toRef(obj, key), toRefs(obj) - shallowRef, shallowReactive, customRef - markRaw(obj):标记对象不被代理 - proxyRefs(obj):把对象属性的 ref 自动解包(常用于返回 setup 的对象,便于模板使用) --- 总结(一句话) - ref 是值的容器(灵活,常用于原始值及需要整体替换的对象);reactive 是对象的 Proxy(更自然地操作对象属性)。了解两者的访问方式(.value vs 直接属性)、解构坑(toRefs)和浅/深区别,就能在实际项目中正确选择。 --- toRef 和 toRefs的区别 toRef 和 toRefs 是 Vue 3 响应式系统中非常关键的两个工具函数,主要用于 保持响应性 —— 特别是在你从 reactive 对象中“解构属性”时。 下面我会详细讲它们的 区别、用途、源码原理,再配上清晰的图和示例。 --- 🧩 一句话区别总结 暂时无法在飞书文档外展示此内容 --- 🧠 为什么要有它们? 当你从一个 reactive 对象中“解构属性”时,响应性会丢失。 ❌ 普通解构会丢失响应性 ```js const state = reactive({ count: 0, name: '小明' }) const { count } = state // ❌ count 不是响应式的 count++ // 修改不会触发视图更新 ``` 原因: 解构后得到的是普通值,而不是响应式引用(Vue 的 Proxy 失效了)。 --- ✅ 解决办法:toRef ```js import { reactive, toRef } from 'vue' const state = reactive({ count: 0 }) const count = toRef(state, 'count') console.log(count.value) // 0 count.value++ console.log(state.count) // 1 ✅ 保持同步 ``` ✨ 特点: - toRef(state, 'count') 返回一个 ref 对象,其 .value 始终指向 state.count。 - 它 不会创建新的值或副本,只是创建了一个“引用镜像”。 - 常用于“传递单个属性”给子组件或 watch。 --- ✅ 多属性情况:toRefs ```js import { reactive, toRefs } from 'vue' const state = reactive({count: 0,name: '小明' }) // 把所有属性都转成 ref const { count, name } = toRefs(state) count.value++ name.value = '小东' console.log(state.count) // 1 ✅ console.log(state.name) // 小东 ✅ ✨ 特点: - 它会遍历对象的所有 key,执行 toRef(state, key)。 - 返回的对象与原对象 结构相同,但每个属性都是 ref。 - 常用于在 setup() 里返回响应式状态给模板: return { ...toRefs(state) // 模板中直接用 {{ count }} {{ name }} } ``` --- ⚙️ 底层关系图(简化版) ┌────────────────────────┐ │ reactive对象 state │ │ { │ │ count: 0, │ │ name: "小明" │ │ } │ └────────────┬───────────┘ │ ├── toRef(state, "count") → Ref对象 { value ↔ state.count } │ └── toRefs(state) → { count: Ref, name: Ref } --- 🧩 常见使用场景对比 ✅ 场景 1:只需要一个属性(推荐用 toRef) ```js const user = reactive({ name: 'Tom', age: 18 }) const age = toRef(user, 'age') // watch 单个字段 watch(age, (v) => console.log('年龄变化', v)) ``` ✅ 场景 2:需要在模板中用多个属性(推荐用 toRefs) ```js const user = reactive({ name: 'Tom', age: 18 }) return { ...toRefs(user) // 模板中直接用 {{ name }} {{ age }} } --- ``` ✅ 场景 3:嵌套响应式对象 ```js const state = reactive({user: { name: 'Tom', age: 18 } }) const user = toRef(state, 'user') // user 是一个 ref,指向整个对象 user.value.age++ // 更新响应式 ```
考研结束!我的云图库项目终于可以继续啦
考完研啦!虽然结果未知,但总算能松口气,把精力放回自己的小项目上了。 今天想和大家分享一下我一直在做的「云图库」。这段时间偷偷给它加了不少功能,当然也引入了不少新 bug 😂。我的想法很简单:在有限的资源里,做一个体验还不错的小玩意。 目前的计划是「先解决有没有,再解决好不好」。按照这个节奏,估计至少还需要三个月才能把基础功能都搞定。 现在功能拓展得有点杂,先分享几张界面截图,希望能给同样在做小项目的朋友一些灵感。也欢迎大家多提提优化建议! 体验地址:https://yuemutuku.com 计划:加入腾讯云内容审核,sts服务,邮件服务,权限控制,游标机制,缓存机制,举报机制,加入协同过滤的推荐功能,集成LangChain实现AI客服。优化通知机制,评论、点赞、分享的互动体系,优化内容审核机制。 不打算做ai多模态这块的内容,涉及到图片的这块成本相对较高,感觉和项目的初心不太符合。 前端代码预计本周内会开源出来,后端涉及的私密内容比较多,开源的时候会迟一点。 后续开源代码仓库:https://github.com/humenglover 求star。 pc端(部分):                 移动端(部分):              
Vue 3 + vue-dnd-kit 构建的交互式布局项目,模拟LeetCode 编辑页
## 1 目标与使用场景 - 复刻并扩展 LeetCode 编辑页的多面板体验,验证布局预设、分组拖拽、拆分与分屏调整的可行性。 - 适合做刷题工作台、数据/运维/BI 等多区域交互界面原型,强调状态可控、布局灵活、组件可复用。 - 成功标准:布局切换无状态污染;拖拽/拆分不丢失激活态;空组/空容器自动收缩,保持紧凑视图。 - demo 地址 :https://github.com/DavidHLP/leetcode-layout ## 2 UI / 体验设计 - 顶部 Header: - 左:导航与题目跳转(前一题/后一题/随机),HoverCard 提供快捷键提示。 - 中:运行/提交/笔记入口,保持一致的高亮与无阴影按钮风格。 - 右:布局切换下拉,预览卡以 mini-map 形式展示布局,选中态带描边与勾选。 - 面板: - header tab 使用轻量按钮,激活态加深色,拖拽时降低透明度。 - 内容区域暂为占位文本,留接口嵌入实际 Problem/Code/Test 模块。 - 主题: - `src/style.css` 定义 Tailwind v4 inline 主题变量,可快速替换背景、圆角、分割线;暗色主题通过 `.dark` 变量切换。 ### UI 预览     ## 3 总体架构 ### 3.1 技术栈 - **框架层**:Vue 3 (Composition API) + TypeScript - **构建工具**:Vite 7 - **状态管理**:Pinia - **路由**:Vue Router 4 - **样式**:Tailwind CSS v4 - **拖拽库**:`@vue-dnd-kit` (https://github.com/ZiZIGY/vue-dnd-kit) - **图标库**:Lucide Icons ### 3.2 目录结构与分层 ``` src/ ├── components/ui/ # 通用 UI 组件 │ ├── button/ # 按钮组件 │ ├── dropdown-menu/ # 下拉菜单组件 │ ├── hover-card/ # 悬浮卡片组件 │ ├── kbd/ # 键盘快捷键组件 │ ├── resizable/ # 可调整尺寸组件(核心) │ └── separator/ # 分隔线组件 │ ├── features/layout/ # 布局领域特性组件 │ ├── headers/ # 顶部标题栏组件 │ │ ├── LayoutHeaderLeft.vue # 左侧导航 │ │ ├── LayoutHeaderCenter.vue # 中间操作按钮 │ │ └── LayoutHeaderControls.vue # 右侧布局切换 │ ├── panels/ # 面板组件 │ │ ├── LayoutPanel.vue # 面板容器(核心) │ │ ├── LayoutPanelContent.vue # 面板内容区 │ │ ├── LayoutPanelHeader.vue # 面板标签页(拖拽起点) │ │ └── PanelDropOverlay.vue # 拖拽落点视觉反馈 │ └── tree/ # 布局树组件 │ ├── LayoutTree.vue # 树根容器 │ └── LayoutTreeNode.vue # 递归节点(核心) │ ├── stores/ # 状态管理 │ └── headerStore.ts # 布局与分组状态(核心) │ ├── pages/ # 页面级组件 │ └── LayoutWorkbench.vue # 工作台页面(页面壳) │ ├── lib/ # 工具函数 │ └── utils.ts │ ├── App.vue # 根组件 ├── main.ts # 入口文件 └── style.css # 全局样式(Tailwind 配置) ``` ### 3.3 分层设计 #### 页面层(Pages) - **责任**:页面结构组织、预设配置生成、初始化与切换逻辑 - **核心文件**:`LayoutWorkbench.vue` - **不包含**:具体业务逻辑、拖拽细节、树渲染逻辑 #### 领域状态层(Domain Store) - **责任**:布局树、分组数据、激活态管理,跨组/拆分操作 - **核心文件**:`headerStore.ts` - **设计原则**:最小必要状态,视图层通过计算属性求导出可见性 **Store 状态结构:** ```typescript // headerStore.ts 核心状态 const layoutConfig = ref<LayoutNode | null>(null) // 布局树 const activeGroupId = ref<string | null>(null) // 当前激活的分组 ID const headerGroups = ref<HeaderGroup[]>([]) // 所有分组数据 // 计算属性 const visibleGroups = computed(() => headerGroups.value.filter((g) => g.headers.length > 0)) ``` **核心方法:** - `initData(groups, layout)` - 初始化或重置全部状态 - `updateGroupHeaders(groupId, newHeaders)` - 同组重排 - `moveHeaderBetweenGroups(...)` - 跨组移动 - `splitGroup(...)` - 拆分创建新分组 - `setActiveGroup(groupId)` - 设置激活分组 #### 视图层(View Components) **布局树视图**(`features/layout/tree`): - `LayoutTree.vue` - 根容器,provide 拖拽上下文 - `LayoutTreeNode.vue` - 递归渲染节点,处理容器/叶子分叉 **面板视图**(`features/layout/panels`): - `LayoutPanel.vue` - 面板主容器,协调拖拽/拆分/激活态 - `LayoutPanelHeader.vue` - 可拖拽的标签页,阈值检测 - `PanelDropOverlay.vue` - 拖拽落点区域视觉反馈 - `LayoutPanelContent.vue` - 内容占位器 **顶部栏视图**(`features/layout/headers`): - `LayoutHeaderLeft.vue` - 左侧导航按钮 - `LayoutHeaderCenter.vue` - 中间操作区 - `LayoutHeaderControls.vue` - 右侧布局切换下拉 **通用 UI 组件**(`components/ui`): - `resizable/` - 可调整尺寸的面板组(ResizablePanelGroup/Panel/Handle) - `button/`、`dropdown-menu/`、`hover-card/`、`kbd/`、`separator/` - 常用 UI 组件 ### 3.4 数据流边界 #### Store 边界 ``` LayoutWorkbench (Page) │ ├── initData(groups, layout) → headerStore │ └── 读取 layoutConfig, headerGroups, activeGroupId ``` **原则:** - Store 只持有最小必要状态(布局树 + 分组 + 激活) - 不在 Store 中存储计算结果(如 `visibleChildren`) - 视图层通过 `computed` 动态计算可见性 #### 拖拽上下文边界 ``` LayoutTree (Root) │ ├── provide('dragState', dragState) ├── provide('moveHeaderBetweenGroups', ...) └── provide('splitGroup', ...) │ └── LayoutTreeNode │ └── LayoutPanel │ └── LayoutPanelHeader (inject 消费) ``` **原则:** - 拖拽状态和方法通过 `provide/inject` 跨层级传递 - 避免每层 props 中转,减少组件耦合 - 仅在拖拽相关组件中 `inject`,不污染其他组件 ## 4 数据模型与不变量 ### 4.1 核心数据结构 #### LayoutNode(布局节点) ```typescript export interface LayoutNode { id: string // 节点唯一标识 type: 'container' | 'leaf' // 节点类型 direction?: 'horizontal' | 'vertical' // 容器方向(仅容器节点) size?: number // 初始尺寸比例(百分比,如 50) children?: LayoutNode[] // 子节点(仅容器节点) groupId?: string // 关联的分组 ID(仅叶子节点) groupMetadata?: { // 分组元数据(用于 UI 展示) id: string name: string } } ``` **设计约束:** - **容器节点**(`type: 'container'`): - 必须有 `direction` 属性(水平/垂直) - 必须有 `children` 数组,至少包含 2 个子节点 - 不应有 `groupId` 和 `groupMetadata` - **叶子节点**(`type: 'leaf'`): - 必须绑定 `groupId` 关联到某个 HeaderGroup - 可选 `groupMetadata` 用于快速 UI 展示,避免查询 - 不应有 `direction` 和 `children` - **size 属性**:表示节点在父容器中的初始占比(0-100),用于 ResizablePanel 的 `default-size` **树结构特点:** - 采用递归嵌套结构,支持任意深度的布局组合 - 根节点通常是容器节点,定义整体布局方向 - 通过 DFS(深度优先搜索)遍历和修改树结构 #### HeaderGroup(分组模型) ```typescript export interface HeaderGroup { id: string // 分组唯一标识 name: string // 分组名称 headers: HeaderModel[] // 该分组下的所有标签页 } export interface HeaderModel { id: number // 标签页唯一 ID index: number // 在分组中的顺序索引(从 0 开始) title: string // 显示标题 icon: string // Lucide 图标名称 color?: string // 文本颜色 iconColor?: string // 图标颜色 } ``` **设计约束:** - 同一分组内的 `index` 必须连续(0, 1, 2, ...),用于维护视觉顺序 - 跨组移动或组内重排后,必须调用重新编号逻辑(见 `updateGroupHeaders` / `moveHeaderBetweenGroups`) - `id` 在全局唯一,用于激活态判断和拖拽识别 #### DragState(拖拽状态) ```typescript const dragState = ref<{ sourceGroupId: string | null // 拖拽源分组 ID sourceIndex: number | null // 拖拽源在分组中的索引 }>({ sourceGroupId: null, sourceIndex: null, }) ``` **设计原理:** - 由 `LayoutTree` 通过 `provide` 注入,所有子组件通过 `inject` 共享 - 在 `handleDragStart` 时记录来源,在 `handleDragEnd` 或 `handleOverlayDrop` 时清空 - 避免了深层 props 传递,支持跨层级拖拽状态同步 ### 4.2 可视化规则(不变量) 1. **空组隐藏规则**: - 分组的 `headers` 数组为空时,对应的叶子节点不渲染 - 通过 `shouldShowNode` 函数递归检查: ```typescript const shouldShowNode = (layoutNode: LayoutNode): boolean => { if (layoutNode.type === 'leaf' && layoutNode.groupId) { return getGroupHeaders(layoutNode.groupId).length > 0 } if (layoutNode.type === 'container' && layoutNode.children) { return layoutNode.children.some((child) => shouldShowNode(child)) } return false } ``` 2. **容器收缩规则**: - 容器节点的所有子节点都不可见时,容器本身也不渲染 - 通过 `visibleChildren` 计算属性过滤: ```typescript const visibleChildren = computed(() => { if (node.value.type !== 'container' || !node.value.children) { return [] } return node.value.children.filter((child) => shouldShowNode(child)) }) ``` 3. **激活态保持规则**: - `activeGroupId` 始终指向一个存在且可见的分组 - 初始化时选择第一个非空分组: ```typescript const firstVisible = groups.find((g) => g.headers.length > 0) if (firstVisible) { activeGroupId.value = firstVisible.id } ``` - 当前激活的标签页被移除时,自动切换到该分组的第一个标签页: ```typescript watch( () => localHeaders.value, (newHeaders) => { const activeId = activeHeader.value?.id const stillExists = activeId !== undefined && newHeaders.some((header) => header.id === activeId) if (!stillExists) { activeHeader.value = newHeaders[0] || null } }, ) ``` 4. **唯一标识符规则**: - 每个 LayoutNode 的 `id` 在树中唯一(如 `programming-left`、`compact-right`) - HeaderGroup 的 `id` 在分组数组中唯一(如 `problem-info`、`code-editor`) - 拆分时动态生成新分组 ID:`group-${Date.now()}` ## 5 状态流转 - 初始化(挂载时): 1. `LayoutWorkbench` 依据当前枚举调用 `getXxxLayoutConfig` 生成 `groups + layout` 深拷贝。 2. `headerStore.initData` 写入 store,并选中第一个非空组作为 `activeGroupId`。 - 预设切换: 1. UI 下拉触发 `handleLayoutChange(layoutKey)`。 2. 根据 key 生成新配置;调用 `initData` 重置状态(避免旧分组引用被污染)。 3. 视图层根据新 `layoutConfig` 递归渲染,自动隐藏空分支。 - 同组重排:`updateGroupHeaders` 在本地数组复制、重排并重写 `index`。 - 跨组移动:`moveHeaderBetweenGroups` 在源/目标组 splice,分别重写 `index`,保持有序。 ## 6 拖拽与拆分交互(核心流程) ### 6.1 拖拽触发机制 #### 阈值检测(防误触) `LayoutPanelHeader` 使用自定义的指针事件处理,而非直接使用 `@vue-dnd-kit` 的默认拖拽: ```typescript const MOVE_THRESHOLD = 5 // 5px 移动阈值 const onPointerDown = (e: PointerEvent) => { pointerDownEvent.value = e initialPosition.value = { x: e.clientX, y: e.clientY } hasMoved.value = false } const onPointerMove = (e: PointerEvent) => { if (!pointerDownEvent.value || !initialPosition.value) return const deltaX = Math.abs(e.clientX - initialPosition.value.x) const deltaY = Math.abs(e.clientY - initialPosition.value.y) // 超过阈值才触发拖拽 if (!hasMoved.value && (deltaX > MOVE_THRESHOLD || deltaY > MOVE_THRESHOLD)) { hasMoved.value = true if (pointerDownEvent.value) { emit('drag-start', pointerDownEvent.value, handleDragStart) } } } ``` **设计优势:** - 避免点击时误触发拖拽 - 用户必须移动超过 5px 才开始拖拽,提升体验 - 通过 `hasMoved` 标志区分点击和拖拽,只有未移动时才触发 `header-click` 事件 #### 拖拽状态记录 ```typescript const handleDragStart = ( index: number, event: PointerEvent, handleStart: (e: PointerEvent) => void, ) => { draggedIndex.value = index if (dragState) { dragState.value.sourceGroupId = props.group || 'default' dragState.value.sourceIndex = index } handleStart(event) // 调用 @vue-dnd-kit 的 handleDragStart } ``` ### 6.2 同组/跨组落点判断 #### 拖拽悬停(dragover) `LayoutPanel` 在每个标签页的 `pointerover` 事件中计算鼠标位置: ```typescript const handleDragOver = (index: number, event: PointerEvent) => { if (dragState?.value.sourceGroupId && dragState.value.sourceIndex !== null) { const target = event.currentTarget as HTMLElement const rect = target.getBoundingClientRect() const mouseX = event.clientX const elementCenter = rect.left + rect.width / 2 overIndex.value = index dropPosition.value = mouseX < elementCenter ? 'before' : 'after' } } ``` **视觉反馈:** - `overIndex` 和 `dropPosition` 用于渲染插入指示器(可通过 CSS 高亮边框) - 鼠标在元素左半部分时为 `before`,右半部分为 `after` #### 拖拽释放(dragend) ```typescript const handleDragEnd = () => { if ( dragState?.value.sourceGroupId && dragState.value.sourceIndex !== null && overIndex.value !== null ) { const sourceGroupId = dragState.value.sourceGroupId const sourceIndex = dragState.value.sourceIndex const targetGroupId = props.group || 'default' let targetIndex = overIndex.value if (dropPosition.value === 'after') { targetIndex += 1 } if (sourceGroupId === targetGroupId) { // 同组重排 const newHeaders = [...localHeaders.value] const [movedItem] = newHeaders.splice(sourceIndex, 1) if (movedItem) { const adjustedTargetIndex = targetIndex > sourceIndex ? targetIndex - 1 : targetIndex newHeaders.splice(adjustedTargetIndex, 0, movedItem) localHeaders.value = newHeaders props.onUpdate(newHeaders) // 触发 updateGroupHeaders } } else if (moveHeaderBetweenGroups) { // 跨组移动 moveHeaderBetweenGroups(sourceGroupId, targetGroupId, sourceIndex, targetIndex) } } // 清空状态 draggedIndex.value = null overIndex.value = null dropPosition.value = null if (dragState) { dragState.value.sourceGroupId = null dragState.value.sourceIndex = null } } ``` **关键点:** - 同组时需要调整 `targetIndex`:如果目标索引大于源索引,先移除源项会导致索引偏移,需 `-1` 修正 - 跨组时直接调用 store 方法,由 store 统一处理索引重写 ### 6.3 拆分交互(splitGroup) #### 四象限检测 `PanelDropOverlay` 组件在拖拽进行时显示,监听鼠标位置判断落点区域: ```typescript const handleMouseMove = (e: MouseEvent) => { const target = e.currentTarget as HTMLElement const rect = target.getBoundingClientRect() const x = e.clientX - rect.left const y = e.clientY - rect.top const w = rect.width const h = rect.height const threshold = 0.25 // 边缘区域占比 25% let zone: 'top' | 'bottom' | 'left' | 'right' | 'center' = 'center' if (y < h * threshold) { zone = 'top' } else if (y > h * (1 - threshold)) { zone = 'bottom' } else if (x < w * threshold) { zone = 'left' } else if (x > w * (1 - threshold)) { zone = 'right' } if (activeZone.value !== zone) { activeZone.value = zone emit('zone-change', zone) } } ``` **视觉反馈:** - 顶部 25% 区域:显示 "Split Top" 蓝色高亮 - 底部 25% 区域:显示 "Split Bottom" 蓝色高亮 - 左侧 25% 区域:显示 "Split Left" 蓝色高亮 - 右侧 25% 区域:显示 "Split Right" 蓝色高亮 - 中心区域:显示 "Add to Group" 虚线边框 #### 拆分逻辑实现 ```typescript const splitGroup = ( sourceGroupId: string, targetGroupId: string, sourceIndex: number, direction: 'top' | 'bottom' | 'left' | 'right' | 'center', ) => { // 1. 从源分组移除标签页 const sourceGroup = headerGroups.value.find((g) => g.id === sourceGroupId) const [movedItem] = sourceGroup.headers.splice(sourceIndex, 1) sourceGroup.headers.forEach((h, i) => (h.index = i)) // 重新编号 if (direction === 'center') { // 追加到目标分组 const targetGroup = headerGroups.value.find((g) => g.id === targetGroupId) targetGroup.headers.push(movedItem) targetGroup.headers.forEach((h, i) => (h.index = i)) return } // 2. 创建新分组 const newGroupId = `group-${Date.now()}` const newGroup: HeaderGroup = { id: newGroupId, name: 'New Group', headers: [movedItem], } headerGroups.value.push(newGroup) // 3. 修改布局树(DFS 查找并就地修改) const modifyLayout = (node: LayoutNode): boolean => { if (node.type === 'leaf' && node.groupId === targetGroupId) { // 保存原叶子节点信息 const originalGroupId = node.groupId const originalGroupMetadata = node.groupMetadata // 将叶子节点转为容器节点 node.type = 'container' node.groupId = undefined node.groupMetadata = undefined node.direction = direction === 'left' || direction === 'right' ? 'horizontal' : 'vertical' // 构造两个新叶子节点 const newLeafNode: LayoutNode = { id: `leaf-${newGroupId}`, type: 'leaf', groupId: newGroupId, groupMetadata: { id: newGroupId, name: 'New Group' }, size: 50, } const originalLeafNode: LayoutNode = { id: `leaf-${originalGroupId}-${Date.now()}`, type: 'leaf', groupId: originalGroupId, groupMetadata: originalGroupMetadata, size: 50, } // 根据方向决定子节点顺序 if (direction === 'left' || direction === 'top') { node.children = [newLeafNode, originalLeafNode] } else { node.children = [originalLeafNode, newLeafNode] } return true } // 递归查找 if (node.children) { for (const child of node.children) { if (modifyLayout(child)) return true } } return false } modifyLayout(layoutConfig.value) activeGroupId.value = newGroupId // 激活新分组 } ``` **关键设计:** - **就地修改(in-place mutation)**:直接修改找到的节点对象,保持 Vue 响应式引用,避免整树替换导致 UI 重新渲染 - **方向映射**:`top/bottom → vertical`,`left/right → horizontal` - **初始尺寸**:新旧叶子节点各占 50%,用户可拖动 ResizableHandle 调整 - **自动激活**:拆分后自动激活新创建的分组,用户立即看到新面板 ### 6.4 边界处理与状态清理 ```typescript // 源组或目标组不存在时早返回 if (!sourceGroup || !targetGroup) return // 拖拽结束必须清空状态 if (dragState) { dragState.value.sourceGroupId = null dragState.value.sourceIndex = null } ``` **防御性编程:** - 所有跨组操作前验证分组存在性 - 索引访问前检查数组长度 - 拖拽状态在每次操作结束后严格清空,防止残留影响下次交互 ## 7 布局渲染与分屏调整 ### 7.1 递归渲染机制 #### LayoutTreeNode 递归组件 ```vue <template> <div class="h-full w-full"> <!-- 容器节点 --> <ResizablePanelGroup v-if="node.type === 'container' && visibleChildren.length > 0" :id="node.id" :direction="node.direction || 'horizontal'" :class="['h-full w-full gap-2', { 'p-2': isRoot }]" > <template v-for="(child, index) in visibleChildren" :key="child.id"> <ResizablePanel :id="child.id" :default-size="child.size" :min-size="20"> <!-- 递归渲染子节点 --> <LayoutTreeNode :node="child" /> </ResizablePanel> <ResizableHandle v-if="index < visibleChildren.length - 1" with-handle /> </template> </ResizablePanelGroup> <!-- 叶子节点 --> <div v-else-if="node.type === 'leaf' && node.groupId && getGroupHeaders(node.groupId).length > 0" class="h-full cursor-pointer rounded-xl border border-transparent" :class="{ 'border-[#dedede] shadow-sm': activeGroupId === node.groupId }" @click="handleGroupClick(node.groupId)" > <LayoutPanel :headers="getGroupHeaders(node.groupId)" :group="node.groupId" :on-update="(newHeaders) => updateGroupHeaders(node.groupId, newHeaders)" :is-active="activeGroupId === node.groupId" /> </div> </div> </template> ``` **设计要点:** 1. **双模板判断**:根据 `node.type` 决定渲染容器还是叶子节点 2. **可见性过滤**:容器节点只渲染 `visibleChildren`,过滤掉空分组对应的叶子 3. **递归调用**:容器节点内部再次使用 `<LayoutTreeNode :node="child" />`,实现任意深度嵌套 4. **根节点标识**:`isRoot` 为 true 时添加 `p-2` padding,避免布局贴边 ### 7.2 可调整面板(ResizablePanelGroup) #### 核心属性映射 ```vue <ResizablePanelGroup :id="node.id" <!-- 容器唯一 ID --> :direction="node.direction" <!-- 水平/垂直布局 --> class="h-full w-full gap-2" <!-- 子面板间距 2 单位 --> > <ResizablePanel :id="child.id" :default-size="child.size" <!-- 初始占比(如 50) --> :min-size="20" <!-- 最小占比 20% --> > <!-- 面板内容 --> </ResizablePanel> <ResizableHandle v-if="index < visibleChildren.length - 1" with-handle /> </ResizablePanelGroup> ``` **ResizableHandle 插入规则:** - 仅在非最后一个面板后插入 Handle - `with-handle` 属性提供视觉抓手(竖线或横线) - 拖动 Handle 时,相邻面板按比例调整尺寸 **最小尺寸限制:** - `min-size="20"` 确保面板不会被完全压缩 - 用户拖动时,任一面板最小保持 20% 空间 - 防止内容区域完全不可见 ### 7.3 激活态管理 #### 分组级激活(activeGroupId) ```typescript // 在 headerStore.ts 中 const activeGroupId = ref<string | null>(null) const setActiveGroup = (groupId: string) => { activeGroupId.value = groupId } // 在 LayoutTreeNode.vue 中 const handleGroupClick = (groupId: string) => { setActiveGroup(groupId) } ``` **视觉反馈:** - 激活的分组叶子节点显示边框:`:class="{ 'border-[#dedede] shadow-sm': activeGroupId === node.groupId }"` - 传递 `:is-active` 给 `LayoutPanel`,用于内部样式调整 #### 标签页级激活(activeHeader) ```typescript // 在 LayoutPanel.vue 中 const activeHeader = ref<HeaderModel | null>(null) // 监听 headers 变化,自动调整激活项 watch( () => localHeaders.value, (newHeaders) => { const activeId = activeHeader.value?.id const stillExists = activeId !== undefined && newHeaders.some((header) => header.id === activeId) if (!stillExists) { activeHeader.value = newHeaders[0] || null // 回退到第一项 } }, { immediate: true }, ) // 点击标签页切换 const handleHeaderSelect = (header: HeaderModel) => { activeHeader.value = header } ``` **自动回退机制:** - 当前激活的标签页被拖走(跨组移动)时,`stillExists` 检查失败 - 自动激活该分组的第一个标签页(`newHeaders[0]`) - 避免出现"无激活项"的尴尬状态 ### 7.4 布局切换与重置 #### 预设配置生成 ```typescript // 在 LayoutWorkbench.vue 中 const getLeetLayoutConfig = () => { const groups = createInitialHeaderGroups() // 深拷贝分组数据 const layout: LayoutNode = { id: 'programming-root', type: 'container', direction: 'horizontal', children: [ { id: 'programming-left', type: 'leaf', size: 50, groupId: 'problem-info', groupMetadata: { id: 'problem-info', name: 'Problem Information' } }, { id: 'programming-right', type: 'container', direction: 'vertical', size: 50, children: [ { id: 'programming-right-top', type: 'leaf', size: 50, groupId: 'code-editor', ... }, { id: 'programming-right-bottom', type: 'leaf', size: 50, groupId: 'test-info', ... } ] } ] } return { groups, layout } } ``` **深拷贝必要性:** - `createInitialHeaderGroups()` 每次返回新的数组和对象 - 避免多次切换布局时,旧引用污染新配置 - 确保每次 `initData` 都是全新的独立数据 #### 切换流程 ```typescript const handleLayoutChange = (newLayout: 'leet' | 'classic' | 'compact' | 'wide') => { currentLayout.value = newLayout let config: { groups: HeaderGroup[]; layout: LayoutNode } switch (newLayout) { case 'leet': config = getLeetLayoutConfig() break case 'classic': config = getClassicLayoutConfig() break case 'compact': config = getCompactLayoutConfig() break case 'wide': config = getWideLayoutConfig() break } headerStore.initData(config.groups, config.layout) // 全量重置 } ``` **重置策略:** - `initData` 直接覆盖 `headerGroups` 和 `layoutConfig` - 自动重新选择第一个非空分组作为激活态 - 视图层通过响应式自动重新渲染,无需手动刷新
