Vue3 + Go 从零搭建全栈博
用 Vue3 + Go 从零搭建全栈博客:架构设计与工程实践
本文基于一个真实落地的个人博客项目,记录从技术选型、分层架构、目录组织到前后端协作约定的完整设计思路。所有代码片段均来自生产可用的最小实现,而非玩具示例。
1. 为什么选 Vue3 + Go
个人博客看似简单,但要做得"像样"需要覆盖:内容管理(CRUD)、Markdown 渲染、分类/标签、鉴权、搜索、以及一定程度的性能与可维护性。两套技术栈的组合理由很直接:
- 前端 Vue3:
<script setup>+ Composition API 心智负担小,配合 Vite 开发体验极佳;生态(Pinia、Vue Router、Naive UI)成熟,适合独立开发者快速产出。 - 后端 Go:编译型、单二进制部署、并发模型天然适合 API 服务;用 Gin 写 REST、GORM 做 ORM、JWT 做鉴权,整套依赖极少、镜像极小。
个人项目的核心约束不是"功能多",而是长期可维护、部署简单、出问题能自己排查。Go 的单文件部署和 Vue 的静态产物天然契合这一点。
2. 整体架构
采用最经典、也最经得起时间考验的 前后端分离 + 分层后端 架构:
┌─────────────────┐ ┌──────────────────────────────┐
│ Vue3 SPA │ HTTP │ Go API (Gin) │
│ (Vite build) │ ──────▶ │ Handler → Service → Repo │
│ Nginx 托管 │ ◀────── │ │ │ │
└─────────────────┘ JSON │ GORM Redis │
│ │ │ │
│ MySQL (缓存/限流) │
└──────────────────────────────┘
- 前端构建为静态资源,由 Nginx 托管,并通过反向代理把
/api转发到 Go 服务,顺带解决跨域。 - Go 服务只暴露 JSON API,不负责页面渲染(SSR 不在本期范围,后续可叠加 Nuxt 独立站点)。
- 缓存与热点数据(如首页文章列表、站点统计)走 Redis,降低数据库压力。
3. 技术选型
| 层 | 技术 | 用途 |
|---|---|---|
| 前端框架 | Vue 3.4 + TypeScript | UI 与业务逻辑 |
| 构建工具 | Vite 5 | 开发服务器与打包 |
| 状态管理 | Pinia | 全局状态(用户、文章缓存) |
| 路由 | Vue Router 4 | 页面路由与懒加载 |
| UI 组件 | Naive UI | 后台管理界面 |
| HTTP 客户端 | Axios | 请求封装与拦截 |
| 后端框架 | Go 1.22 + Gin | REST API |
| ORM | GORM | 数据库访问 |
| 数据库 | MySQL 8 | 主存储 |
| 缓存 | Redis 7 | 热点缓存、限流 |
| 鉴权 | JWT (access + refresh) | 身份认证 |
| 部署 | Docker + Nginx | 容器化与反向代理 |
4. 后端架构与目录
后端严格遵循 Handler → Service → Repository → Model 分层,每一层只依赖下一层,保证可测试性与职责清晰。
blog-server/
├── cmd/
│ └── server/
│ └── main.go # 启动入口:装配依赖、启动 Gin
├── internal/
│ ├── config/
│ │ └── config.go # 配置加载(viper / 环境变量)
│ ├── model/ # 数据库实体(GORM struct)
│ │ ├── user.go
│ │ ├── post.go
│ │ └── tag.go
│ ├── repository/ # 数据访问层,封装 GORM 查询
│ │ ├── post_repo.go
│ │ └── user_repo.go
│ ├── service/ # 业务逻辑层
│ │ ├── post_service.go
│ │ └── auth_service.go
│ ├── handler/ # HTTP 层,解析参数、调用 service
│ │ ├── post_handler.go
│ │ └── auth_handler.go
│ ├── middleware/ # JWT 鉴权、CORS、限流
│ │ └── jwt.go
│ ├── router/
│ │ └── router.go # 路由注册
│ └── pkg/
│ ├── response/ # 统一响应结构
│ └── jwt/ # token 生成与解析
├── migrations/ # SQL 迁移脚本
├── go.mod
└── Dockerfile
4.1 统一响应结构
所有接口返回统一 envelope,前端用同一套逻辑处理:
// internal/pkg/response/response.go
package response
import "github.com/gin-gonic/gin"
type Body struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data"`
}
func OK(c *gin.Context, data interface{}) {
c.JSON(200, Body{Code: 0, Message: "ok", Data: data})
}
func Error(c *gin.Context, httpStatus, code int, msg string) {
c.JSON(httpStatus, Body{Code: code, Message: msg, Data: nil})
}
4.2 数据模型
// internal/model/post.go
package model
import (
"time"
"gorm.io/gorm"
)
type Post struct {
ID uint `gorm:"primaryKey" json:"id"`
Title string `gorm:"size:200;not null;index" json:"title"`
Slug string `gorm:"size:200;not null;uniqueIndex" json:"slug"`
Summary string `gorm:"size:500" json:"summary"`
Content string `gorm:"type:longtext" json:"content"` // Markdown 原文
Cover string `gorm:"size:500" json:"cover"`
Status int `gorm:"default:1" json:"status"` // 1 草稿 2 已发布
Views int `gorm:"default:0" json:"views"`
AuthorID uint `gorm:"index" json:"author_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
Tags []*Tag `gorm:"many2many:post_tags;" json:"tags"`
}
4.3 Repository 层(分页查询示例)
// internal/repository/post_repo.go
package repository
import (
"blog-server/internal/model"
"gorm.io/gorm"
)
type PostRepo struct {
db *gorm.DB
}
func NewPostRepo(db *gorm.DB) *PostRepo { return &PostRepo{db: db} }
type PostQuery struct {
Page int
PageSize int
Status int
Keyword string
TagID uint
}
func (r *PostRepo) List(q PostQuery) (posts []model.Post, total int64, err error) {
db := r.db.Model(&model.Post{}).Preload("Tags")
if q.Status > 0 {
db = db.Where("status = ?", q.Status)
}
if q.Keyword != "" {
db = db.Where("title LIKE ?", "%"+q.Keyword+"%")
}
if q.TagID > 0 {
db = db.Joins("JOIN post_tags ON post_tags.post_id = posts.id").
Where("post_tags.tag_id = ?", q.TagID)
}
db.Count(&total)
offset := (q.Page - 1) * q.PageSize
err = db.Order("created_at DESC").Offset(offset).Limit(q.PageSize).Find(&posts).Error
return
}
4.4 Service 层(含缓存)
// internal/service/post_service.go
package service
import (
"blog-server/internal/repository"
"context"
"encoding/json"
"fmt"
"time"
)
type PostService struct {
repo *repository.PostRepo
cache *redis.Client
}
func (s *PostService) ListPublished(page, size int) ([]model.Post, int64, error) {
cacheKey := fmt.Sprintf("post:list:%d:%d", page, size)
if data, err := s.cache.Get(context.Background(), cacheKey).Result(); err == nil {
var cached []model.Post
if json.Unmarshal([]byte(data), &cached) == nil {
return cached, 0, nil // 命中缓存,total 由前端分页器单独维护或省略
}
}
posts, total, err := s.repo.List(repository.PostQuery{Page: page, PageSize: size, Status: 2})
if err != nil {
return nil, 0, err
}
if b, e := json.Marshal(posts); e == nil {
s.cache.Set(context.Background(), cacheKey, b, 5*time.Minute)
}
return posts, total, nil
}
4.5 Handler + 路由
// internal/handler/post_handler.go
func (h *PostHandler) List(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "10"))
posts, total, err := h.svc.ListPublished(page, size)
if err != nil {
response.Error(c, 500, 50001, "查询失败")
return
}
response.OK(c, gin.H{"list": posts, "total": total, "page": page, "size": size})
}
// internal/router/router.go
func Register(r *gin.Engine, h *HandlerSet, mw *Middleware) {
api := r.Group("/api")
{
posts := api.Group("/posts")
posts.GET("", postHandler.List) // 公开
posts.GET("/:slug", postHandler.Detail)
admin := api.Group("/admin/posts")
admin.Use(mw.JWT()) // 需要鉴权
admin.POST("", postHandler.Create)
admin.PUT("/:id", postHandler.Update)
admin.DELETE("/:id", postHandler.Delete)
}
}
4.6 JWT 中间件
// internal/middleware/jwt.go
func (m *Middleware) JWT() gin.HandlerFunc {
return func(c *gin.Context) {
token := c.GetHeader("Authorization")
if token == "" || !strings.HasPrefix(token, "Bearer ") {
response.Error(c, 401, 40100, "未登录")
c.Abort()
return
}
claims, err := pkg.ParseAccessToken(strings.TrimPrefix(token, "Bearer "))
if err != nil {
response.Error(c, 401, 40101, "登录已过期")
c.Abort()
return
}
c.Set("uid", claims.UID)
c.Next()
}
}
5. 前端架构与目录
blog-web/
├── src/
│ ├── api/ # 接口封装(按模块拆分)
│ │ ├── request.ts # Axios 实例 + 拦截器
│ │ ├── post.ts
│ │ └── auth.ts
│ ├── stores/ # Pinia 状态
│ │ ├── user.ts
│ │ └── post.ts
│ ├── composables/ # 可复用逻辑
│ │ └── usePosts.ts
│ ├── components/
│ │ ├── PostCard.vue
│ │ └── MarkdownView.vue
│ ├── views/
│ │ ├── Home.vue
│ │ ├── PostDetail.vue
│ │ └── admin/Editor.vue
│ ├── router/index.ts
│ ├── types/index.ts # 前后端共享的类型定义
│ ├── App.vue
│ └── main.ts
5.1 请求封装(统一处理 token 与错误)
// src/api/request.ts
import axios from 'axios'
import { useUserStore } from '@/stores/user'
const request = axios.create({
baseURL: import.meta.env.VITE_API_BASE ?? '/api',
timeout: 10000,
})
request.interceptors.request.use((config) => {
const token = localStorage.getItem('access_token')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
request.interceptors.response.use(
(res) => {
if (res.data.code !== 0) {
// 业务错误统一提示
window.$message?.error(res.data.message)
return Promise.reject(res.data)
}
return res.data.data
},
(err) => {
if (err.response?.status === 401) {
// 触发刷新令牌或跳登录
useUserStore().logout()
}
return Promise.reject(err)
},
)
export default request
5.2 接口模块
// src/api/post.ts
import request from './request'
import type { Post, PageResult } from '@/types'
export function getPosts(page = 1, size = 10) {
return request.get<PageResult<Post>>('/posts', { params: { page, size } })
}
export function getPost(slug: string) {
return request.get<Post>(`/posts/${slug}`)
}
export function createPost(payload: Partial<Post>) {
return request.post<Post>('/admin/posts', payload)
}
5.3 Pinia 状态 + Composable
// src/stores/post.ts
import { defineStore } from 'pinia'
import { getPosts } from '@/api/post'
import type { Post } from '@/types'
export const usePostStore = defineStore('post', {
state: () => ({
list: [] as Post[],
total: 0,
loading: false,
}),
actions: {
async fetchList(page: number, size: number) {
this.loading = true
try {
const data = await getPosts(page, size)
this.list = data.list
this.total = data.total
} finally {
this.loading = false
}
},
},
})
// src/composables/usePosts.ts —— 把"分页 + 拉取"封装成可复用 hook
import { ref } from 'vue'
import { getPosts } from '@/api/post'
import type { Post } from '@/types'
export function usePosts() {
const list = ref<Post[]>([])
const page = ref(1)
const total = ref(0)
const loading = ref(false)
async function load(p = 1) {
loading.value = true
page.value = p
try {
const data = await getPosts(p, 10)
list.value = data.list
total.value = data.total
} finally {
loading.value = false
}
}
return { list, page, total, loading, load }
}
5.4 列表页使用
<!-- src/views/Home.vue -->
<script setup lang="ts">
import { onMounted } from 'vue'
import { usePosts } from '@/composables/usePosts'
import PostCard from '@/components/PostCard.vue'
const { list, total, loading, load } = usePosts()
onMounted(() => load(1))
</script>
<template>
<div class="home">
<PostCard v-for="p in list" :key="p.id" :post="p" />
<n-pagination :item-count="total" @update:page="load" />
</div>
</template>
6. 前后端协作约定
这是全栈项目最容易"各写各的"的地方,提前定好约定能省掉大量联调成本:
- 统一响应:所有成功响应
code === 0,data承载主体;分页统一返回{ list, total, page, size }。 - 错误码分段:
401xx鉴权、403xx权限、404xx资源、422xx参数、5xxxx服务端。前端按段做默认处理。 - 类型共享:
types/index.ts里的Post、PageResult等结构,应与后端 Model 的 JSON tag 一一对应,改一端记得同步另一端。 - 分页与排序:
page(从 1 开始)、size、sort作为 query 参数,服务端偏移计算统一在 Repository 做。 - 鉴权传递:
Authorization: Bearer <access_token>,刷新令牌走refresh_token独立接口,避免泄露风险。
7. 部署要点
# 多阶段构建,最终镜像仅含单二进制
FROM golang:1.22-alpine AS builder
WORKDIR /src
COPY . .
RUN go build -o /server ./cmd/server
FROM alpine:latest
COPY --from=builder /server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
Nginx 负责托管前端静态资源和反代 /api:
server {
listen 80;
root /usr/share/nginx/html;
location / { try_files $uri $uri/ /index.html; }
location /api/ {
proxy_pass http://blog-server:8080;
proxy_set_header Host $host;
}
}
要点:前端用 history 路由时需 try_files 兜底;API 走同源反代可彻底规避 CORS;敏感配置(数据库密码、JWT 密钥)走环境变量注入,不进镜像。
8. 小结与后续
这套架构的核心价值在于分层清晰、依赖简单、部署轻量:
- 后端四层分离让业务逻辑与 HTTP 解耦,便于单测与替换 ORM;
- 前端用 Composable 收敛数据获取逻辑,页面只关心渲染;
- 统一响应 + 类型共享把前后端"契约"显性化,联调成本大幅下降。
可继续演进的方向:接入全文搜索(MySQL FULLTEXT 或 Meilisearch)、增加评论系统、用 Nuxt 单独做 SSR 提升首屏与 SEO、以及把热点统计接入 Prometheus 做可观测性。
架构没有银弹。对个人博客而言,"能长期维护、出问题能自己修"比"用最潮的技术"重要得多。