文章

项目布局最佳实践

项目布局最佳实践

推荐布局(中型项目)

care-mate/
├── cmd/
│   └── server/
│       └── main.go              # 程序入口,只做初始化和启动
├── internal/
│   ├── app/                     # 应用层
│   │   ├── server.go            # HTTP 服务器初始化
│   │   ├── routes.go            # 路由注册
│   │   └── middleware/
│   │       ├── auth.go
│   │       ├── cors.go
│   │       ├── logging.go
│   │       └── recovery.go
│   ├── domain/                  # 领域层
│   │   ├── user/
│   │   │   ├── entity.go        # 领域实体
│   │   │   ├── repository.go    # 仓储接口
│   │   │   └── service.go       # 领域服务
│   │   └── order/
│   │       ├── entity.go
│   │       ├── repository.go
│   │       └── service.go
│   ├── infra/                   # 基础设施层
│   │   ├── persistence/
│   │   │   ├── user_repo.go     # 仓储实现
│   │   │   └── order_repo.go
│   │   ├── cache/
│   │   │   └── redis.go
│   │   ├── mq/
│   │   │   └── kafka.go
│   │   └── external/
│   │       └── client.go
│   ├── interfaces/              # 接口层
│   │   ├── http/
│   │   │   ├── handler/
│   │   │   │   ├── user_handler.go
│   │   │   │   └── order_handler.go
│   │   │   ├── dto/
│   │   │   │   ├── request.go
│   │   │   │   └── response.go
│   │   │   └── middleware/
│   │   └── grpc/
│   │       └── handler/
│   └── config/
│       └── config.go            # 配置定义与加载
├── pkg/                         # 可复用的公共库
│   ├── logger/
│   ├── response/                # 统一响应格式
│   ├── validator/               # 请求验证
│   └── errors/                  # 错误码定义
├── api/
│   └── openapi/
│       └── swagger.yaml
├── configs/
│   ├── config.yaml
│   ├── config.dev.yaml
│   └── config.prod.yaml
├── deployments/
│   ├── docker/
│   │   └── Dockerfile
│   └── k8s/
│       ├── deployment.yaml
│       └── service.yaml
├── scripts/
│   ├── build.sh
│   └── migrate.sh
├── migrations/
│   ├── 001_init.up.sql
│   └── 001_init.down.sql
├── .golangci.yml
├── Makefile
├── go.mod
└── go.sum

main.go 应该多简洁

// cmd/server/main.go
package main

import (
    "context"
    "log/slog"
    "os"
    "os/signal"
    "syscall"

    "github.com/qingsongchou/care-mate/internal/app"
    "github.com/qingsongchou/care-mate/internal/config"
)

// 由 ldflags 注入
var (
    Version   = "dev"
    BuildTime = "unknown"
    Commit    = "unknown"
)

func main() {
    // 1. 加载配置
    cfg, err := config.Load()
    if err != nil {
        slog.Error("failed to load config", "error", err)
        os.Exit(1)
    }

    // 2. 初始化日志
    logger := initLogger(cfg)
    logger.Info("starting server",
        "version", Version,
        "build_time", BuildTime,
        "commit", Commit,
    )

    // 3. 初始化应用
    app, err := app.New(cfg, logger)
    if err != nil {
        logger.Error("failed to init app", "error", err)
        os.Exit(1)
    }

    // 4. 启动(在 goroutine 中)
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    go func() {
        if err := app.Run(); err != nil {
            logger.Error("server error", "error", err)
            cancel()
        }
    }()

    // 5. 等待中断信号
    sigCh := make(chan os.Signal, 1)
    signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
    sig := <-sigCh
    logger.Info("received signal, shutting down", "signal", sig)

    // 6. 优雅关闭
    if err := app.Shutdown(ctx); err != nil {
        logger.Error("shutdown error", "error", err)
        os.Exit(1)
    }
    logger.Info("server stopped")
}

统一响应格式

// pkg/response/response.go
package response

type Response struct {
    Code    int         `json:"code"`
    Message string      `json:"message"`
    Data    interface{} `json:"data,omitempty"`
    TraceID string      `json:"trace_id,omitempty"`
}

func JSON(w http.ResponseWriter, status int, data interface{}) {
    w.Header().Set("Content-Type", "application/json; charset=utf-8")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(Response{
        Code:    0,
        Message: "success",
        Data:    data,
    })
}

func Error(w http.ResponseWriter, status int, code int, message string) {
    w.Header().Set("Content-Type", "application/json; charset=utf-8")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(Response{
        Code:    code,
        Message: message,
    })
}

// 分页响应
type PageResponse struct {
    List    interface{} `json:"list"`
    Total   int64       `json:"total"`
    Page    int         `json:"page"`
    Size    int         `json:"size"`
    Pages   int         `json:"pages"`
}

func PageJSON(w http.ResponseWriter, list interface{}, total int64, page, size int) {
    pages := int(math.Ceil(float64(total) / float64(size)))
    JSON(w, http.StatusOK, PageResponse{
        List:  list,
        Total: total,
        Page:  page,
        Size:  size,
        Pages: pages,
    })
}

错误码体系

// pkg/errors/codes.go
package errors

type AppError struct {
    Code    int    `json:"code"`
    Message string `json:"message"`
    Err     error  `json:"-"`
}

func (e *AppError) Error() string { return e.Message }
func (e *AppError) Unwrap() error { return e.Err }

// 错误码定义(按模块分段)
const (
    // 通用错误 10000-19999
    ErrInternal      = 10000
    ErrBadRequest    = 10001
    ErrUnauthorized  = 10002
    ErrForbidden     = 10003
    ErrNotFound      = 10004
    ErrConflict      = 10005
    ErrValidation    = 10006
    ErrRateLimit     = 10007

    // 用户模块 20000-29999
    ErrUserNotFound   = 20001
    ErrUserExists     = 20002
    ErrInvalidPassword = 20003
    ErrTokenExpired   = 20004

    // 订单模块 30000-39999
    ErrOrderNotFound  = 30001
    ErrOrderStatus    = 30002
)

var errorMessages = map[int]string{
    ErrInternal:       "内部错误",
    ErrBadRequest:     "请求参数错误",
    ErrUnauthorized:   "未授权",
    ErrForbidden:      "无权限",
    ErrNotFound:       "资源不存在",
    ErrUserNotFound:   "用户不存在",
    ErrUserExists:     "用户已存在",
    ErrInvalidPassword: "密码错误",
}

func New(code int) *AppError {
    msg, ok := errorMessages[code]
    if !ok {
        msg = "未知错误"
    }
    return &AppError{Code: code, Message: msg}
}

func Wrap(code int, err error) *AppError {
    return &AppError{Code: code, Message: errorMessages[code], Err: err}
}

// 错误码 → HTTP 状态码
func HTTPStatus(code int) int {
    switch {
    case code >= 10000 && code < 20000:
        switch code {
        case ErrBadRequest, ErrValidation:
            return http.StatusBadRequest
        case ErrUnauthorized, ErrTokenExpired:
            return http.StatusUnauthorized
        case ErrForbidden:
            return http.StatusForbidden
        case ErrNotFound:
            return http.StatusNotFound
        case ErrConflict:
            return http.StatusConflict
        case ErrRateLimit:
            return http.StatusTooManyRequests
        default:
            return http.StatusInternalServerError
        }
    case code >= 20000 && code < 30000:
        if code == ErrUserNotFound || code == ErrUserExists {
            return http.StatusBadRequest
        }
        return http.StatusBadRequest
    default:
        return http.StatusBadRequest
    }
}