从零搭建全栈博客系统:Go + Vue 3 + Docker 全流程实战
前言
一直想拥有一个属于自己的博客系统,不想用现成的 WordPress 或 Hexo,而是想从零开始,亲手搭建一个前后端分离的全栈应用。经过一段时间的开发,最终完成了 JunBlog ------ 一个基于 Go + Vue 3 的全栈个人博客系统。
本文将分享整个项目的技术选型、架构设计、核心功能实现以及 Docker 部署方案,希望能给同样想造轮子的朋友一些参考。
技术栈总览
| 层级 | 技术选型 |
|---|---|
| 后端 | Go 1.25 + Gin + GORM |
| 前端 | Vue 3 + Vite + Vue Router 4 |
| 数据库 | MySQL 8.0 + Redis 7 |
| 认证 | JWT + GitHub OAuth |
| 部署 | Docker Compose + Nginx + Jenkins CI/CD |
选择 Go 做后端是因为它的高性能、简洁语法和出色的并发能力;Vue 3 则是因为上手快、生态完善,配合 Vite 开发体验极佳。
项目架构设计
后端:分层架构
后端采用经典的 分层架构,参考了 Go 社区推荐的标准项目布局:
blog_backend/
├── cmd/server/main.go # 程序入口
├── configs/config.yaml # 配置文件
├── internal/
│ ├── app/app.go # 应用初始化(依赖注入)
│ ├── api/
│ │ ├── router.go # 路由注册
│ │ └── v1/ # API 版本控制
│ │ ├── auth/ # 认证模块
│ │ ├── article/ # 文章模块
│ │ ├── category/ # 分类模块
│ │ ├── tag/ # 标签模块
│ │ ├── comment/ # 评论模块
│ │ ├── interaction/ # 互动模块(点赞/收藏)
│ │ ├── user/ # 用户模块
│ │ └── setting/ # 站点设置
│ ├── middleware/ # 中间件(JWT、CORS、日志)
│ ├── model/
│ │ ├── entity/ # 数据库实体
│ │ └── dto/ # 数据传输对象
│ ├── repository/ # 数据访问层
│ └── service/ # 业务逻辑层
├── pkg/ # 公共包
│ ├── config/ # 配置加载
│ ├── database/ # 数据库连接
│ ├── jwt/ # JWT 工具
│ ├── logger/ # 日志(Zap)
│ ├── response/ # 统一响应
│ └── errors/ # 错误处理
└── Makefile # 构建脚本
核心设计原则:
- 关注点分离:每层只关心自己的职责,Repository 层只做数据操作,Service 层只做业务逻辑
- 依赖注入 :通过
app.go统一初始化和注入依赖,避免循环依赖 - 接口驱动:Repository 和 Service 都定义了接口,方便测试和替换实现
前端:模块化组织
bolg_forntend/src/
├── App.vue # 根组件
├── main.js # 入口
├── router/index.js # 路由配置
├── services/api.js # Axios API 封装
├── pages/ # 页面组件
│ ├── HomePage.vue
│ ├── ArticlePage.vue
│ ├── LoginPage.vue
│ └── ...
├── components/ # 通用组件
├── composables/ # 组合式函数
├── modules/admin/ # 后台管理模块
├── data/ # 静态数据
└── style.css # 全局样式
前端保持轻量,没有引入 Vuex/Pinia 等状态管理库,而是通过 组合式函数(Composables) 管理共享状态,对于个人博客来说完全够用。
核心功能实现
1. JWT 认证体系
JWT 认证是整个系统的安全基石。实现思路:
Token 双令牌机制:
- Access Token:有效期 15 分钟,用于接口鉴权
- Refresh Token:有效期 7 天,用于无感刷新
认证中间件核心逻辑:
go
// middleware/auth.go
func Auth() gin.HandlerFunc {
return func(c *gin.Context) {
// 1. 从 Header 获取 Authorization
authHeader := c.GetHeader("Authorization")
// 2. 解析 Bearer Token
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
response.Unauthorized(c, "令牌格式错误")
c.Abort()
return
}
// 3. 验证 Token 有效性
claims, err := jwt.ParseToken(parts[1])
if err != nil {
response.Unauthorized(c, err.Error())
c.Abort()
return
}
// 4. 检查用户状态(是否被封禁)
userRepo := repository.NewUserRepository(database.GetMySQL())
user, err := userRepo.FindByID(claims.UserID)
if !user.Status {
response.Forbidden(c, "账号已被封禁")
c.Abort()
return
}
// 5. 将用户信息存入 Context,供后续 handler 使用
c.Set("user_id", claims.UserID)
c.Set("role", claims.Role)
c.Next()
}
}
路由分层设计很清晰:
go
// 公开路由 - 无需认证
r.articleCtrl.RegisterPublicRoutes(v1)
// 需认证路由 - 必须登录
authorized := v1.Group("/")
authorized.Use(middleware.Auth())
r.userCtrl.RegisterRoutes(authorized)
// 管理路由 - 必须 admin 角色
admin := v1.Group("/admin")
admin.Use(middleware.Auth())
admin.Use(middleware.RequireRole("admin"))
r.articleCtrl.RegisterAdminRoutes(admin)
2. GitHub OAuth 第三方登录
这是项目中比较有意思的功能。整体流程如下:
用户点击「GitHub 登录」
↓
前端跳转 GitHub 授权页面
↓
用户授权后,GitHub 回调带 code
↓
前端将 code 发送给后端
↓
后端用 code 换取 access_token
↓
后端用 access_token 获取用户信息
↓
查找/创建用户 → 生成 JWT → 返回前端
关键实现 - OAuth 回调处理:
go
// service/auth_service.go
func (s *authService) GitHubLogin(code string) (*AuthResponse, error) {
// 1. 用 code 换取 access_token
token, err := s.getGitHubToken(code)
if err != nil {
return nil, err
}
// 2. 获取 GitHub 用户信息
githubUser, err := s.getGitHubUser(token)
if err != nil {
return nil, err
}
// 3. 查找或创建用户
user, err := s.userRepo.FindByGitHubID(githubUser.ID)
if err != nil {
// 首次登录,创建用户
user = &entity.User{
GitHubID: githubUser.ID,
GitHubLogin: githubUser.Login,
Username: githubUser.Login,
Avatar: githubUser.AvatarURL,
Role: "user",
}
s.userRepo.Create(user)
}
// 4. 生成 JWT
return s.generateToken(user)
}
前端回调页面处理:
javascript
// pages/GithubCallbackPage.vue
onMounted(async () => {
const urlParams = new URLSearchParams(window.location.search)
const code = urlParams.get('code')
if (code) {
try {
const res = await api.githubLogin(code)
// 保存 token 并跳转首页
localStorage.setItem('token', res.data.token)
router.push('/')
} catch (error) {
console.error('GitHub 登录失败:', error)
}
}
})
3. 文章管理功能
博客的核心功能,支持:
- Markdown 编辑器(md-editor-v3)
- 图片上传(限制 10MB,支持 jpg/png/gif/webp/svg)
- 分类和标签管理
- 文章状态管理(草稿/已发布)
- 按热度、时间排序
- 关键词搜索
Docker 部署方案
采用 Docker Compose 一键部署,包含 4 个服务:
yaml
# docker-compose.yml
services:
mysql:
image: mysql:8.0
volumes:
- mysql_data:/var/lib/mysql
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
# 只绑定 127.0.0.1,外部无法访问
ports:
- "127.0.0.1:3306:3306"
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
# 不暴露端口到宿主机
command: redis-server --appendonly yes --requirepass junblog_redis_2026
backend:
build:
context: ../blog_backend
dockerfile: Dockerfile
volumes:
- ./uploads:/app/uploads
- ./logs:/app/logs
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
frontend:
build:
context: ../bolg_forntend
dockerfile: Dockerfile
ports:
- "80:80"
depends_on:
- backend
安全设计亮点:
- MySQL 只绑定
127.0.0.1,外部无法直接访问数据库 - Redis 不暴露端口到宿主机,仅 Docker 内部网络可访问
- 数据库密码通过
.env文件管理,不硬编码在 docker-compose.yml 中 - 后端也不直接暴露端口,通过 Nginx 反向代理访问
Nginx 配置:
nginx
# 前端静态资源
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html; # SPA 路由支持
}
# 后端 API 代理
location /api {
proxy_pass http://junblog-backend:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# 上传文件代理
location ^~ /uploads {
proxy_pass http://junblog-backend:8080;
}
# 静态资源缓存(1年)
location ~* \.(js|css|png|jpg|ico|svg|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Gzip 压缩
gzip on;
gzip_types text/plain text/css application/javascript application/json;
CI/CD 流水线
项目配置了 Jenkins 流水线,实现自动化构建和部署:后续碍于我的服务器性能不够,我舍弃了Jenkins自动化部署
Jenkins Pipeline 流程:
┌─────────┐ ┌──────────────┐ ┌─────────────┐ ┌─────────┐
│ Checkout │ → │ Build Backend│ → │Upload Files │ → │ Deploy │
│ 检出代码 │ │ 交叉编译 Go │ │ SCP 上传 │ │ 重启服务 │
└─────────┘ └──────────────┘ └─────────────┘ └─────────┘
后端交叉编译:
bash
# 在 Windows 上编译 Linux 二进制
set CGO_ENABLED=0
set GOOS=linux
set GOARCH=amd64
go build -o server.exe ./cmd/server
Go 的交叉编译能力非常方便,一条命令就能在 Windows 上编译出 Linux 的可执行文件。
Dockerfile 极简设计:
dockerfile
# 后端 - 基于 Alpine,最终镜像仅约 15MB
FROM alpine:latest
RUN apk --no-cache add ca-certificates tzdata
WORKDIR /app
COPY server .
COPY configs ./configs
RUN mkdir -p uploads logs && chmod +x server
EXPOSE 8080
CMD ["./server"]
功能模块一览
| 模块 | 功能 | 说明 |
|---|---|---|
| 认证 | 注册/登录/JWT/GitHub OAuth | 双令牌机制,支持第三方登录 |
| 文章 | CRUD/Markdown/图片上传 | 支持分类、标签、状态管理 |
| 分类/标签 | 树形分类、标签关联 | 分类支持层级结构 |
| 评论 | 发表/审核/管理 | 支持评论审核机制 |
| 互动 | 点赞/收藏 | Redis 缓存计数 |
| 用户 | 个人信息/角色/封禁 | 管理员可管理用户 |
| 站点设置 | 关于页面/系统配置 | 后台可配置站点信息 |
| 后台管理 | 全功能管理面板 | 文章/用户/评论/设置管理 |
踩坑与经验
1. GORM 的 N+1 查询问题
在查询文章列表时,如果每篇文章都单独查一次分类和标签,会产生 N+1 问题。解决方案是使用 GORM 的 Preload:
go
// 一次性预加载关联数据
db.Preload("Category").Preload("Tags").Find(&articles)
2. Docker 网络安全
最初把 MySQL 端口直接映射到 0.0.0.0:3306,这意味着任何人只要知道服务器 IP 就能尝试连接数据库。后来改为 127.0.0.1:3306:3306,只允许本机访问。
3. 前端 SPA 路由刷新 404
Vue Router 使用 history 模式时,刷新页面会 404。解决方案是在 Nginx 配置中添加:
nginx
try_files $uri $uri/ /index.html;
快速开始
Docker 一键部署:
bash
git clone https://github.com/yourname/junblog.git
cd junblog/deploy
# 配置环境变量
cp .env.example .env
vim .env # 设置数据库密码等
# 启动
docker-compose up -d
# 访问
# 前端:http://your-server-ip
# 后端 API:http://your-server-ip/api/v1/health
本地开发:
bash
# 后端
cd blog_backend
go run ./cmd/server
# 前端
cd bolg_forntend
npm install
npm run dev # 访问 http://localhost:5173
总结
这个项目虽然定位是个人博客,但在架构上并没有偷懒:
- 后端:分层架构 + 接口驱动 + 中间件链,具备良好的可测试性和可扩展性
- 前端:模块化组织 + 组合式函数,代码清晰易维护
- 部署:Docker Compose + Nginx 反向代理 + Jenkins CI/CD,一键部署
- 安全:JWT 双令牌 + RBAC 角色控制 + Docker 网络隔离
整个项目从零搭建到现在,收获了很多。如果你也想搭建自己的博客系统,欢迎参考这个项目。有问题欢迎在评论区交流!