系列文章:Go 聊天室实战系列(6/6)

项目地址https://gitee.com/gaogaohang/go-land_-learning

技术栈:Go 1.23 + Gin + PostgreSQL + Redis + Vue3


一、Docker 容器化

1.1 Dockerfile

# 构建阶段
FROM golang:1.23-alpine AS builder
​
WORKDIR /build
​
# 安装依赖
RUN apk add --no-cache git
​
# 配置 Go 代理(加速下载)
ENV GOPROXY=https://goproxy.cn,direct
​
# 复制依赖文件
COPY go.mod go.sum ./
RUN go mod download
​
# 复制源代码
COPY . .
​
# 编译(静态链接,支持 CGO)
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
    -ldflags="-w -s" \
    -o /build/go-chat \
    main.go
​
# 运行阶段
FROM alpine:latest
​
WORKDIR /app
​
# 安装 CA 证书(HTTPS 需要)
RUN apk --no-cache add ca-certificates tzdata
​
# 从构建阶段复制二进制文件
COPY --from=builder /build/go-chat /app/go-chat
COPY --from=builder /build/config.prod.yaml /app/config.yaml
​
# 暴露端口
EXPOSE 8080
​
# 健康检查
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
    CMD wget -qO- http://localhost:8080/health || exit 1
​
# 启动服务
CMD ["/app/go-chat", "-config", "/app/config.yaml"]

1.2 .dockerignore

# Git
.git
.gitignore
​
# 开发文件
*.md
.air.toml
Makefile
​
# 测试文件
*_test.go
coverage.out
​
# 前端(单独构建)
web/node_modules
web/dist
​
# 日志
*.log
logs/
​
# 临时文件
tmp/
bin/

1.3 docker-compose.yml

version: '3.8'
​
services:
  # 应用服务
  app:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: go-chat-app
    ports:
      - "8080:8080"
    environment:
      - CHAT_ENV=prod
      - CHAT_DATABASE_HOST=postgres
      - CHAT_REDIS_HOST=redis
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    volumes:
      - ./logs:/app/logs
      - ./uploads:/app/uploads
    restart: unless-stopped
    networks:
      - chat-network
​
  # PostgreSQL
  postgres:
    image: postgres:14-alpine
    container_name: go-chat-db
    environment:
      - POSTGRES_USER=chatuser
      - POSTGRES_PASSWORD=chatpass123
      - POSTGRES_DB=chatdb
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./scripts/init.sql:/docker-entrypoint-initdb.d/init.sql
    ports:
      - "5432:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U chatuser -d chatdb"]
      interval: 10s
      timeout: 5s
      retries: 5
    restart: unless-stopped
    networks:
      - chat-network
​
  # Redis
  redis:
    image: redis:7-alpine
    container_name: go-chat-redis
    command: redis-server --appendonly yes
    volumes:
      - redis_data:/data
    ports:
      - "6379:6379"
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5
    restart: unless-stopped
    networks:
      - chat-network
​
  # Nginx(反向代理 + 前端)
  nginx:
    image: nginx:alpine
    container_name: go-chat-nginx
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./web/dist:/usr/share/nginx/html
      - ./nginx.conf:/etc/nginx/nginx.conf
      - ./ssl:/etc/nginx/ssl
    depends_on:
      - app
    restart: unless-stopped
    networks:
      - chat-network
​
volumes:
  postgres_data:
  redis_data:
​
networks:
  chat-network:
    driver: bridge

二、生产环境配置

2.1 config.prod.yaml

server:
  port: 8080
  mode: release  # release 模式(无 debug 日志)
  read_timeout: 30s
  write_timeout: 30s
​
database:
  host: ${CHAT_DATABASE_HOST:localhost}
  port: 5432
  user: chatuser
  password: ${CHAT_DATABASE_PASSWORD:chatpass123}
  db_name: chatdb
  max_open_conns: 100
  max_idle_conns: 20
  conn_max_lifetime: 1h
​
redis:
  host: ${CHAT_REDIS_HOST:localhost}
  port: 6379
  password: ${CHAT_REDIS_PASSWORD:}
  db: 0
  pool_size: 50
​
jwt:
  secret: ${CHAT_JWT_SECRET:change-this-in-production}
  expire_hour: 720  # 30 天
​
log:
  level: warn  # 生产环境只记录警告及以上
  output: file
  file_path: /app/logs/app.log
  max_size: 100  # MB
  max_backups: 5
  max_age: 30    # 天
​
upload:
  path: /app/uploads
  max_size: 10485760  # 10MB
  allowed_types:
    - image/jpeg
    - image/png
    - image/gif

2.2 环境变量管理

# .env.production
CHAT_ENV=prod
CHAT_DATABASE_HOST=192.168.1.100
CHAT_DATABASE_PASSWORD=strong_password_here
CHAT_REDIS_HOST=192.168.1.101
CHAT_JWT_SECRET=your-super-secret-key-min-32-chars
CHAT_SERVER_PORT=9090

三、性能优化

3.1 数据库连接池

// internal/database/postgres.go
​
func InitPostgres() {
    dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable TimeZone=Asia/Shanghai",
        config.DB.Host, config.DB.Port, config.DB.User, config.DB.Password, config.DB.DBName)
    
    db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
    if err != nil {
        logger.Fatalf("数据库连接失败:%v", err)
    }
    
    sqlDB, err := db.DB()
    if err != nil {
        logger.Fatalf("获取 SQL DB 失败:%v", err)
    }
    
    // 连接池配置
    sqlDB.SetMaxOpenConns(config.DB.MaxOpenConns)    // 最大连接数
    sqlDB.SetMaxIdleConns(config.DB.MaxIdleConns)    // 空闲连接数
    sqlDB.SetConnMaxLifetime(time.Hour)              // 连接最大生命周期
    
    // 验证连接
    if err := sqlDB.Ping(); err != nil {
        logger.Fatalf("数据库 Ping 失败:%v", err)
    }
    
    logger.Info("数据库连接成功")
}

3.2 Redis 缓存策略

// internal/service/cache.go
​
package service
​
import (
    "context"
    "encoding/json"
    "time"
)
​
// CacheService 缓存服务
type CacheService struct {
    redis *redis.Client
}
​
// GetUserProfile 获取用户资料(带缓存)
func (s *CacheService) GetUserProfile(ctx context.Context, userID int64) (*model.PublicUser, error) {
    key := fmt.Sprintf("user:profile:%d", userID)
    
    // 1. 尝试从缓存获取
    data, err := s.redis.Get(ctx, key).Bytes()
    if err == nil {
        var user model.PublicUser
        json.Unmarshal(data, &user)
        return &user, nil
    }
    
    // 2. 从数据库获取
    user, err := s.userRepo.GetByID(userID)
    if err != nil {
        return nil, err
    }
    
    // 3. 写入缓存(5 分钟过期)
    data, _ = json.Marshal(user.ToPublic())
    s.redis.Set(ctx, key, data, 5*time.Minute)
    
    return user.ToPublic(), nil
}
​
// InvalidateUserCache 失效用户缓存
func (s *CacheService) InvalidateUserCache(ctx context.Context, userID int64) {
    key := fmt.Sprintf("user:profile:%d", userID)
    s.redis.Del(ctx, key)
}

3.3 消息队列(可选)

// 使用 Redis Stream 作为消息队列
// internal/queue/message_queue.go
​
package queue
​
type MessageQueue struct {
    redis *redis.Client
    streamKey string
}
​
// Enqueue 入队
func (q *MessageQueue) Enqueue(ctx context.Context, msg *websocket.Message) error {
    data, _ := json.Marshal(msg)
    return q.redis.XAdd(ctx, &redis.XAddArgs{
        Stream: q.streamKey,
        Values: map[string]interface{}{
            "data": data,
            "ts":   time.Now().UnixMilli(),
        },
    }).Err()
}
​
// Dequeue 出队(阻塞)
func (q *MessageQueue) Dequeue(ctx context.Context) (*websocket.Message, error) {
    result, err := q.redis.XRead(ctx, &redis.XReadArgs{
        Streams: []string{q.streamKey, "0"},
        Count:   1,
        Block:   5 * time.Second,
    }).Result()
    
    if err != nil {
        return nil, err
    }
    
    var msg websocket.Message
    json.Unmarshal([]byte(result[0].Messages[0].Values["data"].(string)), &msg)
    
    // 确认消费
    q.redis.XDel(ctx, q.streamKey, result[0].Messages[0].ID)
    
    return &msg, nil
}

四、监控与日志

4.1 Prometheus 监控

// internal/middleware/prometheus.go
​
package middleware
​
import (
    "github.com/prometheus/client_golang/prometheus"
    "github.com/prometheus/client_golang/prometheus/promauto"
    "github.com/gin-gonic/gin"
    "time"
)
​
var (
    httpRequests = promauto.NewCounterVec(
        prometheus.CounterOpts{
            Name: "http_requests_total",
            Help: "Total HTTP requests",
        },
        []string{"method", "path", "status"},
    )
    
    httpDuration = promauto.NewHistogramVec(
        prometheus.HistogramOpts{
            Name:    "http_request_duration_seconds",
            Help:    "HTTP request duration in seconds",
            Buckets: prometheus.DefBuckets,
        },
        []string{"method", "path"},
    )
    
    wsConnections = promauto.NewGauge(
        prometheus.GaugeOpts{
            Name: "websocket_connections",
            Help: "Current WebSocket connections",
        },
    )
)
​
// Prometheus Prometheus 监控中间件
func Prometheus() gin.HandlerFunc {
    return func(c *gin.Context) {
        start := time.Now()
        
        c.Next()
        
        // 记录指标
        duration := time.Since(start).Seconds()
        httpRequests.WithLabelValues(c.Request.Method, c.FullPath(), string(c.Writer.Status())).Inc()
        httpDuration.WithLabelValues(c.Request.Method, c.FullPath()).Observe(duration)
    }
}

4.2 Grafana 仪表盘

{
  "dashboard": {
    "title": "Go Chat 监控",
    "panels": [
      {
        "title": "QPS",
        "targets": [
          {
            "expr": "rate(http_requests_total[1m])"
          }
        ]
      },
      {
        "title": "响应时间 P99",
        "targets": [
          {
            "expr": "histogram_quantile(0.99, http_request_duration_seconds_bucket)"
          }
        ]
      },
      {
        "title": "WebSocket 连接数",
        "targets": [
          {
            "expr": "websocket_connections"
          }
        ]
      },
      {
        "title": "数据库连接池",
        "targets": [
          {
            "expr": "go_goroutines"
          }
        ]
      }
    ]
  }
}

4.3 日志收集(ELK)

# filebeat.yml
filebeat.inputs:
- type: log
  enabled: true
  paths:
    - /app/logs/*.log
  json.keys_under_root: true
  
output.elasticsearch:
  hosts: ["elasticsearch:9200"]
  index: "go-chat-%{+yyyy.MM.dd}"

五、CI/CD

5.1 GitHub Actions

# .github/workflows/ci.yml
​
name: CI/CD
​
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
​
jobs:
  test:
    runs-on: ubuntu-latest
    
    services:
      postgres:
        image: postgres:14
        env:
          POSTGRES_PASSWORD: testpass
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
      redis:
        image: redis:7-alpine
        options: >-
          --health-cmd "redis-cli ping"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    
    steps:
    - uses: actions/checkout@v3
    
    - name: Set up Go
      uses: actions/setup-go@v4
      with:
        go-version: '1.23'
    
    - name: Install dependencies
      run: go mod download
    
    - name: Run tests
      run: go test -v -race -coverprofile=coverage.out ./...
      env:
        DATABASE_URL: postgres://postgres:testpass@localhost:5432/postgres?sslmode=disable
        REDIS_URL: redis://localhost:6379
    
    - name: Upload coverage
      uses: codecov/codecov-action@v3
      with:
        file: ./coverage.out
​
  build:
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    
    steps:
    - uses: actions/checkout@v3
    
    - name: Build Docker image
      run: docker build -t go-chat:${{ github.sha }} .
    
    - name: Push to registry
      run: |
        docker login -u ${{ secrets.DOCKER_USER }} -p ${{ secrets.DOCKER_PASS }}
        docker push go-chat:${{ github.sha }}
    
    - name: Deploy
      run: |
        ssh ${{ secrets.DEPLOY_HOST }} << 'EOF'
          cd /opt/go-chat
          docker-compose pull
          docker-compose up -d
        EOF

六、安全加固

6.1 SQL 注入防护

// 正确:使用参数化查询
db.Where("username = ?", username).First(&user)
​
// 错误:字符串拼接
db.Where(fmt.Sprintf("username = '%s'", username)).First(&user)

6.2 XSS 防护

// 前端转义
import { escape } from 'lodash'
​
const safeContent = escape(userInput)
​
// 后端设置 CSP 头
func SecurityHeaders() gin.HandlerFunc {
    return func(c *gin.Context) {
        c.Header("X-Content-Type-Options", "nosniff")
        c.Header("X-Frame-Options", "DENY")
        c.Header("X-XSS-Protection", "1; mode=block")
        c.Header("Content-Security-Policy", "default-src 'self'")
        c.Next()
    }
}

6.3 限流熔断

// internal/middleware/ratelimit.go
​
package middleware
​
import (
    "golang.org/x/time/rate"
    "sync"
)
​
var (
    visitors = make(map[string]*rate.Limiter)
    mu       sync.Mutex
)
​
// RateLimit 限流中间件
func RateLimit(maxRequests int, burst int) gin.HandlerFunc {
    return func(c *gin.Context) {
        ip := c.ClientIP()
        
        mu.Lock()
        limiter, exists := visitors[ip]
        if !exists {
            limiter = rate.NewLimiter(rate.Limit(maxRequests), burst)
            visitors[ip] = limiter
        }
        mu.Unlock()
        
        if !limiter.Allow() {
            c.JSON(http.StatusTooManyRequests, gin.H{
                "error": "请求过于频繁,请稍后再试",
            })
            c.Abort()
            return
        }
        
        c.Next()
    }
}

七、部署脚本

7.1 deploy.sh

#!/bin/bash
​
set -e
​
echo "🚀 开始部署..."
​
# 1. 拉取最新代码
git pull origin main
​
# 2. 构建前端
cd web
npm install
npm run build
cd ..
​
# 3. 构建 Docker 镜像
docker-compose build
​
# 4. 运行数据库迁移
docker-compose run --rm app ./go-chat migrate
​
# 5. 重启服务
docker-compose up -d
​
# 6. 检查健康状态
sleep 5
curl -f http://localhost:8080/health || exit 1
​
echo "部署完成!"

7.2 备份脚本

#!/bin/bash

# backup.sh - 数据库备份

DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR="/backups"

# PostgreSQL 备份
docker exec go-chat-db pg_dump -U chatuser chatdb > ${BACKUP_DIR}/db_${DATE}.sql

# Redis 备份
docker exec go-chat-redis redis-cli BGSAVE
cp /var/lib/docker/volumes/chat_redis_data/_data/dump.rdb ${BACKUP_DIR}/redis_${DATE}.rdb

# 压缩
tar -czf ${BACKUP_DIR}/backup_${DATE}.tar.gz ${BACKUP_DIR}/db_${DATE}.sql ${BACKUP_DIR}/redis_${DATE}.rdb

# 清理 7 天前的备份
find ${BACKUP_DIR} -name "backup_*.tar.gz" -mtime +7 -delete

echo "备份完成:backup_${DATE}.tar.gz"

八、总结

本系列完成了:

  • 项目架构设计

  • JWT 认证与中间件

  • 用户与好友系统

  • WebSocket 实时通信

  • 私聊与群聊功能

  • 部署与优化