Vue3 + Go 从零搭建全栈博

Moryu 6 阅读 全栈

用 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. 前后端协作约定

这是全栈项目最容易"各写各的"的地方,提前定好约定能省掉大量联调成本:

  1. 统一响应:所有成功响应 code === 0data 承载主体;分页统一返回 { list, total, page, size }
  2. 错误码分段401xx 鉴权、403xx 权限、404xx 资源、422xx 参数、5xxxx 服务端。前端按段做默认处理。
  3. 类型共享types/index.ts 里的 PostPageResult 等结构,应与后端 Model 的 JSON tag 一一对应,改一端记得同步另一端。
  4. 分页与排序page(从 1 开始)、sizesort 作为 query 参数,服务端偏移计算统一在 Repository 做。
  5. 鉴权传递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 做可观测性。

架构没有银弹。对个人博客而言,"能长期维护、出问题能自己修"比"用最潮的技术"重要得多。