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)。使用 cleanenv 或 viper 库可以优雅地处理配置解析、默认值和验证。配置文件应支持多环境覆盖,开发环境使用 .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 服务应优雅地处理终止信号,确保正在处理的请求完成后再退出,避免数据不一致。监听 SIGINT 和 SIGTERM 信号,配合 http.Server.Shutdown() 方法,设置合理的超时时间(如 30 秒), guarantees 在容器编排环境中平滑退出。在关闭 HTTP 服务器之前,还应先停止异步任务消费者和定时任务调度器,释放数据库连接和 Redis 连接。停机期间可以通过负载均衡器的健康检查机制自动将流量切走,实现零停机部署。
八、总结
本文展示了如何用 Go 从零实现一个微型博客后端的核心模块。通过分层架构(Handler → Service → Repository)实现了关注点分离;通过 Redis Sorted Set 实现了高效的时间线缓存和推送;通过游标分页解决了深度分页的性能问题;通过 JWT 中间件实现了无状态鉴权。
要在生产环境运行,还需要补充:数据库连接断开重连、分布式链路追踪、指标监控(Prometheus)、结构化日志(Zap/Logrus)、API 限流与熔断、数据迁移管理(golang-migrate)、以及完整的错误处理和边界情况覆盖。这套代码骨架为进一步的功能扩展(如消息通知、全文搜索、推荐系统)提供了坚实的基础。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。