Go 实战入门(一):从零搭建 Web API 项目


Go 实战入门(一):从零搭建 Web API 项目

一、前言

作为一名后端开发者,学习一门新语言最痛苦的莫过于——看完语法教程,依然不知道怎么写项目。

本系列选择另一条路:直接从实战项目入手,在做项目的过程中学习 Go。不讲语法细节,只关注工程实践。

本篇是系列第一篇,目标是搭建一个完整的 Web API 项目骨架,包含:配置管理、日志系统、数据库操作、HTTP 路由、JWT 认证等核心模块。

完整代码见:go-practical-roadmap


二、项目结构

Go 项目没有强制的目录规范,但社区有一套约定俗成的布局:

01-web-api-template/
├── cmd/server/main.go        # 程序入口
├── internal/                 # 内部包(不对外暴露)
│   ├── api/                  # HTTP 处理层
│   ├── service/              # 业务逻辑层
│   ├── repository/           # 数据访问层
│   ├── model/                # 数据模型
│   ├── config/               # 配置定义
│   ├── middleware/           # 中间件
│   └── app/                  # 应用组装
├── pkg/                      # 可复用的公共包
│   ├── logger/               # 日志封装
│   └── db/                   # 数据库封装
├── configs/config.yaml       # 配置文件
└── Makefile                  # 构建脚本

几个关键点:

  • cmd/ 放可执行程序入口,每个子目录对应一个二进制文件
  • internal/ 是 Go 的特殊目录,里面的包无法被外部项目导入
  • pkg/ 放可以被其他项目复用的代码

三、程序入口与生命周期

main.go

package main

import (
    "fmt"
    "log"
    "go-practical-roadmap/01-web-api-template/internal/app"
)

func main() {
    // 创建应用实例
    application, err := app.NewApp()
    if err != nil {
        log.Fatalf("Failed to create app: %v", err)
    }

    // 在 goroutine 中启动应用
    go func() {
        if err := application.Run(); err != nil {
            log.Fatalf("Failed to run app: %v", err)
        }
    }()

    fmt.Println("Server is running on :8080")

    // 等待中断信号
    application.WaitForInterrupt()

    // 停止应用
    if err := application.Stop(); err != nil {
        log.Fatalf("Failed to stop app: %v", err)
    }
}

为什么需要显式阻塞?

注意 application.WaitForInterrupt() 这行。Go 有个特性:main 函数结束 = 程序退出,不管还有多少 goroutine 在运行。

所以必须用某种方式”挡住” main 函数,否则 HTTP 服务器刚启动程序就退出了。

WaitForInterrupt 的实现很简单:

func (a *App) WaitForInterrupt() {
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit  // 阻塞,直到收到 Ctrl+C
}

优雅停机

收到停止信号后,不能直接退出,要给正在处理的请求一个收尾的机会:

func (a *App) Stop() error {
    // 创建 30 秒超时的 context
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    // 优雅关闭 HTTP 服务(等待请求处理完)
    if err := a.server.Shutdown(ctx); err != nil {
        return err
    }

    // 关闭数据库连接
    db.Close()

    // 刷新日志缓冲
    logger.GlobalLogger.Sync()

    return nil
}

context.WithTimeout 是 Go 的超时控制机制。Shutdown 会等待所有请求处理完毕,但最多等 30 秒,超时就强制关闭。


四、配置管理(Viper)

Viper 是 Go 最流行的配置库,支持 YAML、JSON、环境变量等多种来源。

配置结构体

type Config struct {
    Server   ServerConfig   `mapstructure:"server"`
    Database DatabaseConfig `mapstructure:"database"`
    JWT      JWTConfig      `mapstructure:"jwt"`
    Logger   LoggerConfig   `mapstructure:"logger"`
}

type ServerConfig struct {
    Port int    `mapstructure:"port"`
    Host string `mapstructure:"host"`
    Mode string `mapstructure:"mode"`
}

type DatabaseConfig struct {
    Driver       string `mapstructure:"driver"`
    DSN          string `mapstructure:"dsn"`
    MaxIdleConns int    `mapstructure:"max_idle_conns"`
    MaxOpenConns int    `mapstructure:"max_open_conns"`
}

mapstructure 标签告诉 Viper 如何将 YAML 字段映射到结构体。

加载配置

func LoadConfig() (*Config, error) {
    viper.SetConfigName("config")
    viper.SetConfigType("yaml")
    viper.AddConfigPath("./configs")

    // 设置默认值
    viper.SetDefault("server.port", 8080)
    viper.SetDefault("database.max_idle_conns", 10)

    // 环境变量自动覆盖(前缀 APP_)
    viper.SetEnvPrefix("APP")
    viper.AutomaticEnv()
    viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))

    // 读取配置文件
    if err := viper.ReadInConfig(); err != nil {
        return nil, err
    }

    // 反序列化到结构体
    var config Config
    if err := viper.Unmarshal(&config); err != nil {
        return nil, err
    }

    return &config, nil
}

对应的 config.yaml

server:
  port: 8080
  host: localhost
  mode: debug

database:
  driver: sqlite
  dsn: ./data/app.db
  max_idle_conns: 10
  max_open_conns: 100

jwt:
  secret: your-secret-key
  access_token_exp: 3600

环境变量 APP_DATABASE_DSN 会自动覆盖 database.dsn 的值,方便在不同环境部署。


五、日志系统(Zap)

Zap 是 Uber 开源的高性能日志库,特点是零内存分配和结构化日志。

初始化日志

func NewLogger(level string, format string, outputPath string) (Logger, error) {
    // 解析日志级别
    var zapLevel zapcore.Level
    switch level {
    case "debug":
        zapLevel = zap.DebugLevel
    case "info":
        zapLevel = zap.InfoLevel
    case "error":
        zapLevel = zap.ErrorLevel
    default:
        zapLevel = zap.InfoLevel
    }

    // 配置编码器
    encoderConfig := zap.NewProductionEncoderConfig()
    encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder

    var encoder zapcore.Encoder
    if format == "json" {
        encoder = zapcore.NewJSONEncoder(encoderConfig)
    } else {
        encoder = zapcore.NewConsoleEncoder(encoderConfig)
    }

    // 配置输出(支持日志轮转)
    var output zapcore.WriteSyncer
    if outputPath == "stdout" {
        output = zapcore.AddSync(os.Stdout)
    } else {
        output = zapcore.AddSync(&lumberjack.Logger{
            Filename:   outputPath,
            MaxSize:    100,  // MB
            MaxBackups: 10,
            MaxAge:     30,   // days
        })
    }

    core := zapcore.NewCore(encoder, output, zapLevel)
    logger := zap.New(core, zap.AddCaller())

    return &zapLogger{logger: logger}, nil
}

使用方式

// 结构化日志
logger.Info("User login",
    zap.String("username", "sean"),
    zap.Int("user_id", 123),
    zap.Duration("latency", time.Millisecond*50))

输出(JSON 格式):

{"level":"INFO","ts":"2024-01-01T12:00:00Z","caller":"api/routes.go:42","msg":"User login","username":"sean","user_id":123,"latency":"50ms"}

结构化日志的好处是方便后续用 ELK、Loki 等工具分析检索。


六、数据库(Gorm)

Gorm 是 Go 最流行的 ORM,支持 MySQL、PostgreSQL、SQLite 等数据库。

连接数据库

var globalDB *gorm.DB

func Connect(dsn string, driver string) error {
    var err error

    switch driver {
    case "mysql":
        globalDB, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
    case "postgres":
        globalDB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{})
    case "sqlite":
        globalDB, err = gorm.Open(sqlite.Open(dsn), &gorm.Config{})
    }

    if err != nil {
        return err
    }

    // 配置连接池
    sqlDB, _ := globalDB.DB()
    sqlDB.SetMaxIdleConns(10)
    sqlDB.SetMaxOpenConns(100)
    sqlDB.SetConnMaxLifetime(time.Hour)

    return nil
}

Model 定义

type User struct {
    ID        uint           `gorm:"primaryKey" json:"id"`
    CreatedAt time.Time      `json:"created_at"`
    UpdatedAt time.Time      `json:"updated_at"`
    DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
    Username  string         `gorm:"uniqueIndex;size:50;not null" json:"username"`
    Email     string         `gorm:"uniqueIndex;size:100;not null" json:"email"`
    Password  string         `gorm:"size:255;not null" json:"-"`
    IsActive  bool           `gorm:"default:true" json:"is_active"`
}

几个要点:

  • gorm 标签定义数据库约束(主键、索引、长度等)
  • json 标签定义 JSON 序列化行为,json:"-" 表示忽略该字段
  • gorm.DeletedAt 实现软删除,删除记录时只更新该字段

Repository 层

type UserRepository interface {
    Create(user *model.User) error
    GetByID(id uint) (*model.User, error)
    GetByUsername(username string) (*model.User, error)
    Update(user *model.User) error
    Delete(id uint) error
}

type userRepository struct {
    db *gorm.DB
}

func NewUserRepository(db *gorm.DB) UserRepository {
    return &userRepository{db: db}
}

func (r *userRepository) Create(user *model.User) error {
    return r.db.Create(user).Error
}

func (r *userRepository) GetByUsername(username string) (*model.User, error) {
    var user model.User
    err := r.db.Where("username = ?", username).First(&user).Error
    if err != nil {
        return nil, err
    }
    return &user, nil
}

func (r *userRepository) Delete(id uint) error {
    return r.db.Delete(&model.User{}, id).Error
}

接口 + 实现分离的设计,方便单元测试时 mock。


七、HTTP 服务(Gin)

Gin 是 Go 最流行的 Web 框架,性能优异且 API 简洁。

路由注册

func SetupRoutes(userService service.UserService) *gin.Engine {
    r := gin.New()

    // 内置中间件
    r.Use(gin.Logger())
    r.Use(gin.Recovery())

    // 自定义中间件
    r.Use(middleware.RequestTracerMiddleware())
    r.Use(middleware.CORSMiddleware())

    // 公开路由
    r.GET("/health", healthCheck)
    r.POST("/api/v1/register", func(c *gin.Context) {
        registerHandler(c, userService)
    })
    r.POST("/api/v1/login", func(c *gin.Context) {
        loginHandler(c, userService)
    })

    // 需要认证的路由组
    authorized := r.Group("/")
    authorized.Use(middleware.JWTAuthMiddleware)
    {
        authorized.GET("/api/v1/profile", profileHandler)
    }

    return r
}

请求处理

func registerHandler(c *gin.Context, userService service.UserService) {
    var req dto.RegisterRequest

    // 绑定 JSON 请求体到结构体
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }

    // 调用业务层
    user, err := userService.Register(&req)
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }

    // 返回响应
    c.JSON(http.StatusCreated, gin.H{
        "message": "User registered successfully",
        "data":    user,
    })
}

c.ShouldBindJSON 会自动解析请求体并校验参数(配合 validator 标签),省去大量样板代码。


八、中间件

JWT 认证

type Claims struct {
    UserID   uint   `json:"user_id"`
    Username string `json:"username"`
    jwt.RegisteredClaims
}

// 生成 Token
func GenerateToken(userID uint, username string) (string, error) {
    expirationTime := time.Now().Add(time.Duration(config.GlobalConfig.JWT.AccessTokenExp) * time.Second)

    claims := &Claims{
        UserID:   userID,
        Username: username,
        RegisteredClaims: jwt.RegisteredClaims{
            ExpiresAt: jwt.NewNumericDate(expirationTime),
            IssuedAt:  jwt.NewNumericDate(time.Now()),
        },
    }

    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    return token.SignedString([]byte(config.GlobalConfig.JWT.Secret))
}

// 认证中间件
func JWTAuthMiddleware(c *gin.Context) {
    authHeader := c.GetHeader("Authorization")
    if authHeader == "" {
        c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing Authorization header"})
        c.Abort()
        return
    }

    if !strings.HasPrefix(authHeader, "Bearer ") {
        c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization format"})
        c.Abort()
        return
    }

    tokenString := strings.TrimPrefix(authHeader, "Bearer ")

    claims, err := ValidateToken(tokenString)
    if err != nil {
        c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
        c.Abort()
        return
    }

    // 将用户信息存入 context,后续 handler 可以取用
    c.Set("user_id", claims.UserID)
    c.Set("username", claims.Username)

    c.Next()
}

c.Abort() 会终止后续中间件和 handler 的执行,直接返回响应。


九、业务分层

Service 层

type UserService interface {
    Register(req *dto.RegisterRequest) (*dto.UserProfileResponse, error)
    Login(req *dto.LoginRequest) (string, error)
}

type userService struct {
    userRepo repository.UserRepository
}

func NewUserService(userRepo repository.UserRepository) UserService {
    return &userService{userRepo: userRepo}
}

func (s *userService) Register(req *dto.RegisterRequest) (*dto.UserProfileResponse, error) {
    // 检查用户名是否已存在
    if _, err := s.userRepo.GetByUsername(req.Username); err == nil {
        return nil, errors.New("username already exists")
    }

    // 密码加密
    hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
    if err != nil {
        return nil, err
    }

    // 创建用户
    user := &model.User{
        Username: req.Username,
        Email:    req.Email,
        Password: string(hashedPassword),
    }

    if err := s.userRepo.Create(user); err != nil {
        return nil, err
    }

    // 返回 DTO(不含密码)
    return &dto.UserProfileResponse{
        ID:       user.ID,
        Username: user.Username,
        Email:    user.Email,
    }, nil
}

func (s *userService) Login(req *dto.LoginRequest) (string, error) {
    user, err := s.userRepo.GetByUsername(req.Username)
    if err != nil {
        return "", errors.New("invalid username or password")
    }

    if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
        return "", errors.New("invalid username or password")
    }

    return middleware.GenerateToken(user.ID, user.Username)
}

分层职责

职责
API/Handler 处理 HTTP 请求响应,参数校验
Service 业务逻辑,事务控制
Repository 数据访问,SQL 操作
Model 数据库实体
DTO 接口传输对象,与 Model 分离

DTO 和 Model 分离的好处:

  • 接口返回的字段可以灵活控制,不暴露敏感信息(如密码)
  • 数据库结构变化不影响 API 契约

十、小结

本篇完成了一个 Web API 项目的基础骨架:

  • 程序入口:理解 Go 的启动机制和优雅停机
  • 配置管理:使用 Viper 加载 YAML 配置和环境变量
  • 日志系统:使用 Zap 输出结构化日志
  • 数据库:使用 Gorm 定义 Model 和 Repository
  • HTTP 服务:使用 Gin 注册路由和处理请求
  • 中间件:实现 JWT 认证
  • 业务分层:Service 层处理业务逻辑

  目录