用 Go 从零构建微型博客后端

使用 Go 语言和 Gin 框架从零实现一个生产级的微型博客后端服务,涵盖路由设计、中间件、数据库接入、JWT 鉴权与时间线生成等核心模块。

Go 语言凭借出色的并发模型、简洁的语法和卓越的编译速度,成为构建高并发后端服务的首选语言之一。本文将使用 Go 语言和 Gin 框架,从零实现一个微型博客后端服务的核心模块,涵盖项目结构、路由设计、中间件、JWT 鉴权、数据库接入、时间线 API 和测试覆盖等工程实践。

一、项目结构

miniblog/
├── cmd/
│   └── server/
│       └── main.go              # 应用入口
├── internal/
│   ├── api/
│   │   ├── handler/             # HTTP 处理器
│   │   │   ├── post_handler.go
│   │   │   ├── user_handler.go
│   │   │   ├── feed_handler.go
│   │   │   └── auth_handler.go
│   │   ├── middleware/          # 中间件
│   │   │   ├── auth.go
│   │   │   ├── logger.go
│   │   │   ├── rate_limiter.go
│   │   │   └── recovery.go
│   │   └── router.go            # 路由注册
│   ├── domain/                  # 领域模型
│   │   ├── post.go
│   │   ├── user.go
│   │   └── interaction.go
│   ├── repository/              # 仓储层
│   │   ├── post_repo.go
│   │   ├── user_repo.go
│   │   └── redis/               # Redis 缓存
│   │       ├── timeline_cache.go
│   │       └── counter_cache.go
│   ├── service/                 # 业务逻辑层
│   │   ├── post_service.go
│   │   ├── feed_service.go
│   │   ├── auth_service.go
│   │   └── interaction_service.go
│   └── config/
│       └── config.go            # 配置管理
├── pkg/
│   ├── jwtutil/                 # JWT 工具
│   ├── cursor/                  # 游标编码
│   └── pagination/              # 分页工具
├── migrations/                  # 数据库迁移
├── scripts/
│   └── db_migrate.sh
├── Makefile
├── go.mod
└── Dockerfile

二、领域模型

2.1 核心领域对象

// internal/domain/post.go
package domain

import "time"

type Visibility string

const (
	VisibilityPublic    Visibility = "public"
	VisibilityFollowers Visibility = "followers"
	VisibilityPrivate   Visibility = "private"
)

type Post struct {
	ID        int64      `json:"id" db:"id"`
	UserID    int64      `json:"user_id" db:"user_id"`
	Username  string     `json:"username" db:"username"`
	Content   string     `json:"content" db:"content"`
	Media     []Media    `json:"media,omitempty" db:"-"`
	Mentions  []string   `json:"mentions,omitempty" db:"-"`
	Hashtags  []string   `json:"hashtags,omitempty" db:"-"`
	ReplyTo   *int64     `json:"reply_to,omitempty" db:"reply_to"`
	RootPost  *int64     `json:"root_post,omitempty" db:"root_post"`
	Visibility Visibility `json:"visibility" db:"visibility"`
	
	Counters  PostCounters `json:"counters,omitempty" db:"-"`
	
	CreatedAt time.Time `json:"created_at" db:"created_at"`
	UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
	DeletedAt *time.Time `json:"deleted_at,omitempty" db:"deleted_at"`
}

type Media struct {
	Type   string `json:"type"`
	URL    string `json:"url"`
	Width  int    `json:"width,omitempty"`
	Height int    `json:"height,omitempty"`
}

type PostCounters struct {
	Replies   int64 `json:"replies"`
	Reposts   int64 `json:"reposts"`
	Likes     int64 `json:"likes"`
	Bookmarks int64 `json:"bookmarks"`
	Views     int64 `json:"views"`
}

func (p *Post) Validate() error {
	if len(p.Content) == 0 || len(p.Content) > 2000 {
		return fmt.Errorf("content must be between 1 and 2000 characters")
	}
	return nil
}

type CreatePostRequest struct {
	Content    string     `json:"content" binding:"required,min=1,max=2000"`
	Media      []Media    `json:"media,omitempty"`
	Visibility Visibility `json:"visibility" binding:"oneof=public followers private"`
	ReplyTo    *int64     `json:"reply_to,omitempty"`
}

// internal/domain/user.go
package domain

type User struct {
	ID        int64     `json:"id" db:"id"`
	Username  string    `json:"username" db:"username"`
	Email     string    `json:"email" db:"email"`
	Password  string    `json:"-" db:"password_hash"`  // 不序列化
	DisplayName string  `json:"display_name" db:"display_name"`
	AvatarURL string    `json:"avatar_url" db:"avatar_url"`
	Bio       string    `json:"bio" db:"bio"`
	
	FollowersCount int64 `json:"followers_count" db:"followers_count"`
	FollowingCount int64 `json:"following_count" db:"following_count"`
	PostsCount     int64 `json:"posts_count" db:"posts_count"`
	
	Verified  bool      `json:"verified" db:"verified"`
	Protected bool      `json:"protected" db:"protected"`
	CreatedAt time.Time `json:"created_at" db:"created_at"`
}

type Follow struct {
	FollowerID  int64     `json:"follower_id" db:"follower_id"`
	FollowingID int64     `json:"following_id" db:"following_id"`
	Status      string    `json:"status" db:"status"`  // active | pending | blocked
	CreatedAt   time.Time `json:"created_at" db:"created_at"`
}

2.2 基准领域常量

// internal/domain/constants.go
package domain

const (
	MaxPostLength    = 2000
	TimelinePageSize = 20
	MaxTimelineDepth = 1000  // Redis 缓存最多保留 1000 条
	FanoutThreshold  = 10000 // 超过此粉丝数不推送到时间线
)

三、数据访问层

3.1 PostgreSQL 连接池

// internal/repository/db.go
package repository

import (
	"fmt"
	"time"
	
	"github.com/jmoiron/sqlx"
	_ "github.com/lib/pq"
)

type DB struct {
	*sqlx.DB
}

func New(dsn string) (*DB, error) {
	db, err := sqlx.Connect("postgres", dsn)
	if err != nil {
		return nil, fmt.Errorf("connect to database: %w", err)
	}
	
	// 连接池配置
	db.SetMaxOpenConns(25)
	db.SetMaxIdleConns(5)
	db.SetConnMaxLifetime(5 * time.Minute)
	db.SetConnMaxIdleTime(2 * time.Minute)
	
	return &DB{db}, nil
}

3.2 Post 仓储实现

// internal/repository/post_repo.go
package repository

import (
	"context"
	"database/sql"
	"fmt"
	
	"miniblog/internal/domain"
)

type PostRepository struct {
	db *DB
}

func NewPostRepository(db *DB) *PostRepository {
	return &PostRepository{db: db}
}

func (r *PostRepository) Create(ctx context.Context, post *domain.Post) error {
	query := `
		INSERT INTO posts (user_id, content, visibility, reply_to, root_post, created_at, updated_at)
		VALUES ($1, $2, $3, $4, $5, $6, $7)
		RETURNING id
	`
	
	return r.db.QueryRowxContext(ctx, query,
		post.UserID, post.Content, post.Visibility,
		post.ReplyTo, post.RootPost, post.CreatedAt, post.UpdatedAt,
	).Scan(&post.ID)
}

func (r *PostRepository) GetByID(ctx context.Context, id int64) (*domain.Post, error) {
	var post domain.Post
	query := `
		SELECT id, user_id, content, visibility, reply_to, root_post,
		       created_at, updated_at, deleted_at
		FROM posts
		WHERE id = $1 AND deleted_at IS NULL
	`
	err := r.db.GetContext(ctx, &post, query, id)
	if err == sql.ErrNoRows {
		return nil, fmt.Errorf("post not found: %d", id)
	}
	return &post, err
}

func (r *PostRepository) GetByUser(ctx context.Context, userID int64, cursor *domain.Cursor, limit int) ([]domain.Post, error) {
	query := `
		SELECT id, user_id, content, visibility, reply_to, root_post,
		       created_at, updated_at
		FROM posts
		WHERE user_id = $1 AND deleted_at IS NULL
	`
	args := []interface{}{userID}
	
	if cursor != nil {
		query += ` AND created_at < $2`
		args = append(args, cursor.Timestamp)
	}
	
	query += ` ORDER BY created_at DESC LIMIT $` + fmt.Sprintf("%d", len(args)+1)
	args = append(args, limit)
	
	var posts []domain.Post
	err := r.db.SelectContext(ctx, &posts, query, args...)
	return posts, err
}

func (r *PostRepository) BatchGet(ctx context.Context, ids []int64) ([]domain.Post, error) {
	if len(ids) == 0 {
		return nil, nil
	}
	
	query, args, err := sqlx.In(`
		SELECT id, user_id, content, visibility, reply_to, root_post,
		       created_at, updated_at
		FROM posts
		WHERE id IN (?) AND deleted_at IS NULL
		ORDER BY created_at DESC
	`, ids)
	if err != nil {
		return nil, err
	}
	query = r.db.Rebind(query)
	
	var posts []domain.Post
	err = r.db.SelectContext(ctx, &posts, query, args...)
	return posts, err
}

func (r *PostRepository) SoftDelete(ctx context.Context, id int64) error {
	_, err := r.db.ExecContext(ctx,
		`UPDATE posts SET deleted_at = NOW() WHERE id = $1`,
		id,
	)
	return err
}

3.3 时间线 Redis 缓存

// internal/repository/redis/timeline.go
package redis

import (
	"context"
	"encoding/json"
	"fmt"
	"strconv"
	"time"
	
	"github.com/redis/go-redis/v9"
	"miniblog/internal/domain"
)

type TimelineCache struct {
	client *redis.Client
}

func NewTimelineCache(addr string) *TimelineCache {
	client := redis.NewClient(&redis.Options{
		Addr:     addr,
		PoolSize: 10,
	})
	return &TimelineCache{client: client}
}

func (c *TimelineCache) AddPost(ctx context.Context, userID int64, postID int64, timestamp time.Time) error {
	key := fmt.Sprintf("timeline:%d", userID)
	score := float64(timestamp.Unix())
	
	pipe := c.client.Pipeline()
	pipe.ZAdd(ctx, key, redis.Z{Score: score, Member: postID})
	pipe.ZRemRangeByRank(ctx, key, 0, -(domain.MaxTimelineDepth + 1))
	pipe.Expire(ctx, key, 7*24*time.Hour)
	
	_, err := pipe.Exec(ctx)
	return err
}

func (c *TimelineCache) GetTimeline(ctx context.Context, userID int64, cursor *domain.Cursor, limit int) ([]int64, error) {
	key := fmt.Sprintf("timeline:%d", userID)
	
	var args redis.ZRangeArgs
	if cursor != nil {
		args = redis.ZRangeArgs{
			Key:     key,
			Start:   "(" + fmt.Sprintf("%d", cursor.Timestamp.Unix()),
			Stop:    "-inf",
			ByScore: true,
			Rev:     true,
		}
	} else {
		args = redis.ZRangeArgs{
			Key:   key,
			Start: 0,
			Stop:  int64(limit - 1),
			Rev:   true,
		}
	}
	
	result, err := c.client.ZRangeArgsWithScores(ctx, args).Result()
	if err != nil {
		return nil, err
	}
	
	ids := make([]int64, 0, len(result))
	for _, z := range result {
		id, _ := strconv.ParseInt(z.Member.(string), 10, 64)
		ids = append(ids, id)
	}
	return ids, nil
}

func (c *TimelineCache) FanoutPost(ctx context.Context, followerIDs []int64, postID int64, timestamp time.Time) error {
	if len(followerIDs) == 0 {
		return nil
	}
	
	score := float64(timestamp.Unix())
	pipe := c.client.Pipeline()
	
	// 分批执行,每批最多 1000 个关注者
	batchSize := 1000
	for i := 0; i < len(followerIDs); i += batchSize {
		end := i + batchSize
		if end > len(followerIDs) {
			end = len(followerIDs)
		}
		
		for _, fid := range followerIDs[i:end] {
			key := fmt.Sprintf("timeline:%d", fid)
			pipe.ZAdd(ctx, key, redis.Z{Score: score, Member: postID})
			pipe.ZRemRangeByRank(ctx, key, 0, -(domain.MaxTimelineDepth + 1))
		}
		
		if _, err := pipe.Exec(ctx); err != nil {
			return fmt.Errorf("fanout batch %d-%d: %w", i, end, err)
		}
		pipe = c.client.Pipeline() // 重置 pipeline
	}
	
	return nil
}

func (c *TimelineCache) GetFollowerIDs(ctx context.Context, userID int64) ([]int64, error) {
	key := fmt.Sprintf("followers:%d", userID)
	members, err := c.client.SMembers(ctx, key).Result()
	if err != nil {
		return nil, err
	}
	
	ids := make([]int64, 0, len(members))
	for _, m := range members {
		id, _ := strconv.ParseInt(m, 10, 64)
		ids = append(ids, id)
	}
	return ids, nil
}

四、业务逻辑层

4.1 Post 服务

// internal/service/post_service.go
package service

import (
	"context"
	"regexp"
	"strings"
	"time"
	
	"miniblog/internal/domain"
	"miniblog/internal/repository"
	"miniblog/internal/repository/redis"
)

type PostService struct {
	postRepo      *repository.PostRepository
	userRepo      *repository.UserRepository
	timelineCache *redis.TimelineCache
	counterCache  *redis.CounterCache
}

func NewPostService(
	postRepo *repository.PostRepository,
	userRepo *repository.UserRepository,
	timelineCache *redis.TimelineCache,
	counterCache *redis.CounterCache,
) *PostService {
	return &PostService{
		postRepo:      postRepo,
		userRepo:      userRepo,
		timelineCache: timelineCache,
		counterCache:  counterCache,
	}
}

func (s *PostService) Create(ctx context.Context, userID int64, req domain.CreatePostRequest) (*domain.Post, error) {
	// 1. 获取用户信息
	user, err := s.userRepo.GetByID(ctx, userID)
	if err != nil {
		return nil, err
	}
	
	// 2. 解析内容(提取 @mentions 和 #hashtags)
	mentions, hashtags := s.parseContent(req.Content)
	
	post := &domain.Post{
		UserID:     userID,
		Username:   user.Username,
		Content:    req.Content,
		Mentions:   mentions,
		Hashtags:   hashtags,
		Visibility: req.Visibility,
		ReplyTo:    req.ReplyTo,
		CreatedAt:  time.Now().UTC(),
		UpdatedAt:  time.Now().UTC(),
	}
	
	if err := post.Validate(); err != nil {
		return nil, err
	}
	
	// 3. 若回复其他帖子,填充 root_post
	if req.ReplyTo != nil {
		parent, err := s.postRepo.GetByID(ctx, *req.ReplyTo)
		if err != nil {
			return nil, fmt.Errorf("parent post not found: %w", err)
		}
		if parent.RootPost != nil {
			post.RootPost = parent.RootPost
		} else {
			rootID := parent.ID
			post.RootPost = &rootID
		}
	}
	
	// 4. 写入数据库
	if err := s.postRepo.Create(ctx, post); err != nil {
		return nil, err
	}
	
	// 5. 写入自己的时间线
	if err := s.timelineCache.AddPost(ctx, userID, post.ID, post.CreatedAt); err != nil {
		// 非致命错误,记录日志即可
		fmt.Printf("failed to add post to own timeline: %v\n", err)
	}
	
	// 6. 推送到关注者时间线(异步)
	go s.fanoutPost(userID, post.ID, post.CreatedAt)
	
	return post, nil
}

func (s *PostService) fanoutPost(userID int64, postID int64, timestamp time.Time) {
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()
	
	// 获取粉丝列表
	followerIDs, err := s.timelineCache.GetFollowerIDs(ctx, userID)
	if err != nil {
		fmt.Printf("failed to get followers: %v\n", err)
		return
	}
	
	// 大 V 用户不推送(混合模式)
	if len(followerIDs) > domain.FanoutThreshold {
		return
	}
	
	if err := s.timelineCache.FanoutPost(ctx, followerIDs, postID, timestamp); err != nil {
		fmt.Printf("fanout failed: %v\n", err)
	}
}

var mentionRegex = regexp.MustCompile(`@(\w+)`)
var hashtagRegex = regexp.MustCompile(`#(\w+)`)

func (s *PostService) parseContent(content string) (mentions, hashtags []string) {
	mentionMatches := mentionRegex.FindAllStringSubmatch(content, -1)
	for _, match := range mentionMatches {
		if len(match) > 1 {
			mentions = append(mentions, match[1])
		}
	}
	
	hashtagMatches := hashtagRegex.FindAllStringSubmatch(content, -1)
	for _, match := range hashtagMatches {
		if len(match) > 1 {
			hashtags = append(hashtags, match[1])
		}
	}
	
	return mentions, hashtags
}

// GetFeed 获取用户时间线
func (s *PostService) GetFeed(ctx context.Context, userID int64, cursor *domain.Cursor, limit int) (*domain.FeedResult, error) {
	if limit <= 0 || limit > 50 {
		limit = domain.TimelinePageSize
	}
	
	// 1. 从 Redis 获取时间线帖子 ID
	postIDs, err := s.timelineCache.GetTimeline(ctx, userID, cursor, limit+1)
	if err != nil || len(postIDs) == 0 {
		// 缓存未命中,从数据库拉取
		return s.getFeedFromDB(ctx, userID, cursor, limit)
	}
	
	// 2. 批量获取帖子详情
	hasMore := len(postIDs) > limit
	if hasMore {
		postIDs = postIDs[:limit]
	}
	
	posts, err := s.postRepo.BatchGet(ctx, postIDs)
	if err != nil {
		return nil, err
	}
	
	// 3. 组装结果
	result := &domain.FeedResult{
		Posts:    posts,
		HasMore:  hasMore,
		PageSize: limit,
	}
	
	if hasMore && len(posts) > 0 {
		lastPost := posts[len(posts)-1]
		result.NextCursor = domain.NewCursor(lastPost.ID, lastPost.CreatedAt)
	}
	
	return result, nil
}

func (s *PostService) getFeedFromDB(ctx context.Context, userID int64, cursor *domain.Cursor, limit int) (*domain.FeedResult, error) {
	// 获取关注列表
	followingIDs, err := s.userRepo.GetFollowingIDs(ctx, userID)
	if err != nil {
		return nil, err
	}
	
	// 加上自己
	followingIDs = append(followingIDs, userID)
	
	// 从数据库查询(简化实现,实际应使用更优化的 SQL)
	posts, err := s.postRepo.GetByFollowing(ctx, followingIDs, cursor, limit+1)
	if err != nil {
		return nil, err
	}
	
	// ... 组装结果
	return &domain.FeedResult{Posts: posts}, nil
}

五、HTTP 层

5.1 路由注册

// internal/api/router.go
package api

import (
	"github.com/gin-gonic/gin"
	"miniblog/internal/api/handler"
	"miniblog/internal/api/middleware"
)

func SetupRouter(
	postHandler *handler.PostHandler,
	userHandler *handler.UserHandler,
	feedHandler *handler.FeedHandler,
	authHandler *handler.AuthHandler,
	authMiddleware gin.HandlerFunc,
) *gin.Engine {
	r := gin.New()
	
	// 全局中间件
	r.Use(middleware.Logger())
	r.Use(middleware.Recovery())
	r.Use(middleware.CORS())
	
	// 公开路由
	public := r.Group("/api/v1")
	{
		public.POST("/auth/register", authHandler.Register)
		public.POST("/auth/login", authHandler.Login)
		public.GET("/posts/:id", postHandler.GetByID)
		public.GET("/users/:id/posts", postHandler.GetByUser)
	}
	
	// 需鉴权路由
	auth := r.Group("/api/v1")
	auth.Use(authMiddleware)
	{
		auth.Use(middleware.RateLimiter(100, 60))  // 每分钟100次
		
		// 帖子
		auth.POST("/posts", postHandler.Create)
		auth.DELETE("/posts/:id", postHandler.Delete)
		auth.POST("/posts/:id/like", postHandler.Like)
		auth.DELETE("/posts/:id/like", postHandler.Unlike)
		auth.POST("/posts/:id/repost", postHandler.Repost)
		
		// 时间线
		auth.GET("/feed", feedHandler.GetFeed)
		
		// 用户
		auth.GET("/users/me", userHandler.GetMe)
		auth.POST("/users/:id/follow", userHandler.Follow)
		auth.DELETE("/users/:id/follow", userHandler.Unfollow)
	}
	
	return r
}

5.2 JWT 鉴权中间件

// internal/api/middleware/auth.go
package middleware

import (
	"net/http"
	"strings"
	
	"github.com/gin-gonic/gin"
	"miniblog/pkg/jwtutil"
)

func Auth(secret string) gin.HandlerFunc {
	return func(c *gin.Context) {
		authHeader := c.GetHeader("Authorization")
		if authHeader == "" {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
				"error": "missing authorization header",
			})
			return
		}
		
		parts := strings.SplitN(authHeader, " ", 2)
		if len(parts) != 2 || parts[0] != "Bearer" {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
				"error": "invalid authorization header format",
			})
			return
		}
		
		claims, err := jwtutil.Parse(parts[1], secret)
		if err != nil {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
				"error": "invalid token",
			})
			return
		}
		
		// 将用户信息注入上下文
		c.Set("user_id", claims.UserID)
		c.Set("username", claims.Username)
		c.Next()
	}
}

5.3 Handler 实现

// internal/api/handler/post_handler.go
package handler

import (
	"net/http"
	"strconv"
	
	"github.com/gin-gonic/gin"
	"miniblog/internal/domain"
	"miniblog/internal/service"
)

type PostHandler struct {
	postService *service.PostService
}

func NewPostHandler(postService *service.PostService) *PostHandler {
	return &PostHandler{postService: postService}
}

func (h *PostHandler) Create(c *gin.Context) {
	userID := c.GetInt64("user_id")
	
	var req domain.CreatePostRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	
	post, err := h.postService.Create(c.Request.Context(), userID, req)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	
	c.JSON(http.StatusCreated, gin.H{"success": true, "data": post})
}

func (h *PostHandler) GetByID(c *gin.Context) {
	id, err := strconv.ParseInt(c.Param("id"), 10, 64)
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "invalid post id"})
		return
	}
	
	post, err := h.postService.GetByID(c.Request.Context(), id)
	if err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
		return
	}
	
	c.JSON(http.StatusOK, gin.H{"success": true, "data": post})
}

func (h *PostHandler) GetFeed(c *gin.Context) {
	userID := c.GetInt64("user_id")
	
	// 解析游标
	var cursor *domain.Cursor
	if cursorStr := c.Query("cursor"); cursorStr != "" {
		cursor = domain.ParseCursor(cursorStr)
	}
	
	limit, _ := strconv.Atoi(c.Query("limit"))
	
	result, err := h.postService.GetFeed(c.Request.Context(), userID, cursor, limit)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	
	c.JSON(http.StatusOK, gin.H{
		"success": true,
		"data":    result.Posts,
		"meta": gin.H{
			"has_more":    result.HasMore,
			"next_cursor": result.NextCursor,
		},
	})
}

六、启动与运行

// cmd/server/main.go
package main

import (
	"log"
	"os"
	
	"github.com/gin-gonic/gin"
	"miniblog/internal/api"
	"miniblog/internal/api/handler"
	"miniblog/internal/api/middleware"
	"miniblog/internal/config"
	"miniblog/internal/repository"
	"miniblog/internal/repository/redis"
	"miniblog/internal/service"
)

func main() {
	cfg := config.Load()
	
	// 数据库
	db, err := repository.New(cfg.Database.DSN)
	if err != nil {
		log.Fatal("Failed to connect database:", err)
	}
	
	// Redis
	timelineCache := redis.NewTimelineCache(cfg.Redis.Addr)
	counterCache := redis.NewCounterCache(cfg.Redis.Addr)
	
	// 仓库
	postRepo := repository.NewPostRepository(db)
	userRepo := repository.NewUserRepository(db)
	
	// 服务
	postService := service.NewPostService(postRepo, userRepo, timelineCache, counterCache)
	authService := service.NewAuthService(userRepo, cfg.JWT.Secret)
	
	// Handler
	postHandler := handler.NewPostHandler(postService)
	authHandler := handler.NewAuthHandler(authService)
	feedHandler := handler.NewFeedHandler(postService)
	userHandler := handler.NewUserHandler(userRepo)
	
	// 路由
	authMiddleware := middleware.Auth(cfg.JWT.Secret)
	r := api.SetupRouter(postHandler, userHandler, feedHandler, authHandler, authMiddleware)
	
	// 启动
	port := os.Getenv("PORT")
	if port == "" {
		port = "8080"
	}
	
	log.Printf("Server starting on :%s", port)
	if err := r.Run(":" + port); err != nil {
		log.Fatal("Failed to start server:", err)
	}
}

七、测试

// internal/service/post_service_test.go
package service

import (
	"context"
	"testing"
	"time"
	
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/mock"
	"miniblog/internal/domain"
)

// Mock 仓库
type mockPostRepo struct {
	mock.Mock
}

func (m *mockPostRepo) Create(ctx context.Context, post *domain.Post) error {
	args := m.Called(ctx, post)
	return args.Error(0)
}

func (m *mockPostRepo) GetByID(ctx context.Context, id int64) (*domain.Post, error) {
	args := m.Called(ctx, id)
	return args.Get(0).(*domain.Post), args.Error(1)
}

func TestPostService_Create(t *testing.T) {
	// 准备
	postRepo := new(mockPostRepo)
	// ... 其他 mock
	
	service := NewPostService(postRepo, nil, nil, nil)
	
	// 输入
	req := domain.CreatePostRequest{
		Content:    "Hello, miniblog!",
		Visibility: domain.VisibilityPublic,
	}
	
	// 期望
	postRepo.On("Create", mock.Anything, mock.AnythingOfType("*domain.Post")).Return(nil)
	
	// 执行
	post, err := service.Create(context.Background(), 1, req)
	
	// 验证
	assert.NoError(t, err)
	assert.Equal(t, "Hello, miniblog!", post.Content)
	assert.Equal(t, domain.VisibilityPublic, post.Visibility)
	postRepo.AssertExpectations(t)
}

七、部署与运维

7.1 Docker 容器化

# Dockerfile
FROM golang:1.22-alpine AS builder

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server ./cmd/server

FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/

COPY --from=builder /app/server .
COPY --from=builder /app/migrations ./migrations

EXPOSE 8080
CMD ["./server"]
# docker-compose.yml
version: '3.8'
services:
  api:
    build: .
    ports:
      - "8080:8080"
    environment:
      - DB_DSN=postgres://miniblog:password@db:5432/miniblog?sslmode=disable
      - REDIS_ADDR=redis:6379
      - JWT_SECRET=${JWT_SECRET}
    depends_on:
      - db
      - redis

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: miniblog
      POSTGRES_PASSWORD: password
      POSTGRES_DB: miniblog
    volumes:
      - pgdata:/var/lib/postgresql/data

  redis:
    image: redis:7-alpine
    volumes:
      - redisdata:/data

volumes:
  pgdata:
  redisdata:

7.2 配置管理

生产环境的配置应通过环境变量注入,避免将敏感信息硬编码到代码中。数据库密码、JWT 密钥和第三方 API 凭证必须通过 Secrets 管理(如 Kubernetes Secrets 或 HashiCorp Vault)。使用 cleanenvviper 库可以优雅地处理配置解析、默认值和验证。配置文件应支持多环境覆盖,开发环境使用 .env.local,测试环境使用 .env.test,生产环境完全依赖环境变量。

7.3 健康检查与监控

生产服务必须暴露健康检查端点,便于负载均衡器和容器编排平台判断服务状态:

// 健康检查端点
func HealthCheck(db *sqlx.DB, redisClient *redis.Client) gin.HandlerFunc {
    return func(c *gin.Context) {
        health := gin.H{"status": "healthy", "timestamp": time.Now().UTC()}

        // 数据库连通性检查
        if err := db.PingContext(c.Request.Context()); err != nil {
            c.JSON(http.StatusServiceUnavailable, gin.H{
                "status": "unhealthy", "component": "database", "error": err.Error(),
            })
            return
        }
        health["database"] = "connected"

        // Redis 连通性检查
        if err := redisClient.Ping(c.Request.Context()).Err(); err != nil {
            c.JSON(http.StatusServiceUnavailable, gin.H{
                "status": "unhealthy", "component": "redis", "error": err.Error(),
            })
            return
        }
        health["redis"] = "connected"

        c.JSON(http.StatusOK, health)
    }
}

配合 Prometheus 指标采集和 Grafana 可视化面板,可以实时监控 QPS、延迟分布、错误率和资源使用率等关键指标。告警规则应覆盖 P99 延迟突增、错误率超过阈值和数据库连接池耗尽等场景,通过企业微信、钉钉或 PagerDuty 及时通知值班人员。

7.4 Graceful Shutdown

Go 服务应优雅地处理终止信号,确保正在处理的请求完成后再退出,避免数据不一致。监听 SIGINTSIGTERM 信号,配合 http.Server.Shutdown() 方法,设置合理的超时时间(如 30 秒), guarantees 在容器编排环境中平滑退出。在关闭 HTTP 服务器之前,还应先停止异步任务消费者和定时任务调度器,释放数据库连接和 Redis 连接。停机期间可以通过负载均衡器的健康检查机制自动将流量切走,实现零停机部署。

八、总结

本文展示了如何用 Go 从零实现一个微型博客后端的核心模块。通过分层架构(Handler → Service → Repository)实现了关注点分离;通过 Redis Sorted Set 实现了高效的时间线缓存和推送;通过游标分页解决了深度分页的性能问题;通过 JWT 中间件实现了无状态鉴权。

要在生产环境运行,还需要补充:数据库连接断开重连、分布式链路追踪、指标监控(Prometheus)、结构化日志(Zap/Logrus)、API 限流与熔断、数据迁移管理(golang-migrate)、以及完整的错误处理和边界情况覆盖。这套代码骨架为进一步的功能扩展(如消息通知、全文搜索、推荐系统)提供了坚实的基础。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「miniblog」更多文章

  1. 内容审核与个性化推荐系统设计
  2. 轻社交媒体产品设计方法论
  3. 去中心化笔记与 ActivityPub 联邦协议