跳转到正文
zeno's blog
返回

Go 服务:从接口契约到分层实现

Table of contents

Open Table of contents

TL;DR

写业务代码”无从下手”的本质是逻辑还在脑子里碎片化。解法是固定一条拆解路径——先定接口契约(入参出参),再定领域模型(存什么),最后分层填充业务逻辑(Handler → Service → Repository)。每一层只做一件事,卡壳时用伪代码注释占位,逐步替换为真实代码。


第一步:拆解需求为动作清单

拿到”user-service:注册、登录、获取用户信息”这种需求,先别写代码,把每个功能拆成动作链

功能动作链关键决策点
注册校验入参 → 查重 → 密码加密 → 入库 → 返回唯一性约束放 DB 还是应用层?密码用什么算法?
登录查用户 → 比对密码 → 签发 Token → 返回JWT 还是 Session?Token 过期策略?
获取信息从 Token 解析 UserID → 查库 → 脱敏 → 返回哪些字段不能暴露?缓存策略?

这张表就是你的开发 checklist,后面每一步都在填充这张表。

第二步:定接口契约(从外向内)

先确定”对外暴露什么”,这决定了你的 Handler 层和 DTO。

// 请求/响应结构体(DTO)—— 和数据库模型严格分离
type RegisterRequest struct {
    Email    string `json:"email" binding:"required,email"`
    Password string `json:"password" binding:"required,min=8"`
    Nickname string `json:"nickname" binding:"required"`
}

type LoginRequest struct {
    Email    string `json:"email" binding:"required,email"`
    Password string `json:"password" binding:"required"`
}

type LoginResponse struct {
    Token     string `json:"token"`
    ExpiresAt int64  `json:"expires_at"`
}

type UserInfoResponse struct {
    ID        int64  `json:"id"`
    Email     string `json:"email"`
    Nickname  string `json:"nickname"`
    CreatedAt int64  `json:"created_at"`
    // 注意:没有 password 字段,这就是脱敏
}

路由定义:

// POST /v1/register
// POST /v1/login
// GET  /v1/me        (需要 Auth 中间件)

为什么先定契约? 因为它是最不依赖实现细节的部分。定好入参出参后,Handler 怎么写、Service 怎么调,都有了明确的输入输出边界。

第三步:定领域模型(存什么)

// 数据库模型 —— 和 DTO 是两个东西
type User struct {
    ID           int64     `db:"id"`
    Email        string    `db:"email"`         // UNIQUE 约束
    PasswordHash string    `db:"password_hash"`  // 存的是 bcrypt hash,永远不存明文
    Nickname     string    `db:"nickname"`
    CreatedAt    time.Time `db:"created_at"`
    UpdatedAt    time.Time `db:"updated_at"`
}
CREATE TABLE users (
    id          BIGSERIAL PRIMARY KEY,
    email       TEXT NOT NULL UNIQUE,
    password_hash TEXT NOT NULL,
    nickname    TEXT NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

关键决策:唯一性约束必须放数据库层。 应用层的”先查再插”在并发下是 race condition,DB 的 UNIQUE 约束才是最终防线。应用层的查重只是提前给用户友好提示。

第四步:分层填充(核心)

Go 微服务的标准三层:Handler(接入)→ Service(业务逻辑)→ Repository(数据访问)

Repository 层:只做数据存取

type UserRepository interface {
    Create(ctx context.Context, user *User) error
    GetByEmail(ctx context.Context, email string) (*User, error)
    GetByID(ctx context.Context, id int64) (*User, error)
}

用 interface 定义,实现可以是 GORM、sqlc、pgx——Service 层不关心。

Service 层:业务逻辑的唯一归属地

卡壳时的技巧:先用注释写伪代码,再逐行替换为真实代码。

type UserService struct {
    repo UserRepository
}

func (s *UserService) Register(ctx context.Context, req *RegisterRequest) (*User, error) {
    // 1. 检查邮箱是否已注册
    existing, err := s.repo.GetByEmail(ctx, req.Email)
    if err != nil {
        return nil, fmt.Errorf("check email: %w", err)
    }
    if existing != nil {
        return nil, ErrEmailAlreadyExists // 业务错误,用预定义的 sentinel error
    }

    // 2. 密码加密
    hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
    if err != nil {
        return nil, fmt.Errorf("hash password: %w", err)
    }

    // 3. 构建模型并入库
    user := &User{
        Email:        req.Email,
        PasswordHash: string(hash),
        Nickname:     req.Nickname,
    }
    if err := s.repo.Create(ctx, user); err != nil {
        // 处理 DB 层的唯一约束冲突(并发注册场景)
        if isUniqueViolation(err) {
            return nil, ErrEmailAlreadyExists
        }
        return nil, fmt.Errorf("create user: %w", err)
    }

    return user, nil
}

func (s *UserService) Login(ctx context.Context, req *LoginRequest) (string, error) {
    // 1. 查用户
    user, err := s.repo.GetByEmail(ctx, req.Email)
    if err != nil {
        return "", fmt.Errorf("get user: %w", err)
    }
    if user == nil {
        return "", ErrInvalidCredentials // 不要暴露"用户不存在",统一返回"凭证无效"
    }

    // 2. 比对密码
    if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
        return "", ErrInvalidCredentials
    }

    // 3. 签发 JWT
    token, err := generateJWT(user.ID)
    if err != nil {
        return "", fmt.Errorf("generate token: %w", err)
    }

    return token, nil
}

Handler 层:只做参数绑定、调用 Service、组装响应

func (h *UserHandler) Register(c *gin.Context) {
    var req RegisterRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }

    user, err := h.svc.Register(c.Request.Context(), &req)
    if err != nil {
        // 根据错误类型返回不同 HTTP 状态码
        handleError(c, err)
        return
    }

    c.JSON(http.StatusCreated, toUserInfoResponse(user))
}

每一层的职责边界

做什么不做什么
Handler参数绑定、调 Service、HTTP 状态码映射不写业务判断、不直接操作数据库
Service业务规则、流程编排、错误定义不感知 HTTP/gRPC、不写 SQL
Repository数据 CRUD、SQL 查询不做业务判断(如”邮箱是否已存在”的决策归 Service)

违反边界的典型信号:Handler 里出现 if user.Role == "admin" 这种业务判断,或 Repository 里出现 bcrypt.CompareHashAndPassword 这种业务逻辑。

Go 特有的实践模式

错误处理:用 sentinel error + errors.Is 替代异常体系

// 预定义业务错误
var (
    ErrEmailAlreadyExists = errors.New("email already exists")
    ErrInvalidCredentials = errors.New("invalid credentials")
    ErrUserNotFound       = errors.New("user not found")
)

// Handler 层根据错误类型映射 HTTP 状态码
func handleError(c *gin.Context, err error) {
    switch {
    case errors.Is(err, ErrEmailAlreadyExists):
        c.JSON(http.StatusConflict, gin.H{"error": "邮箱已注册"})
    case errors.Is(err, ErrInvalidCredentials):
        c.JSON(http.StatusUnauthorized, gin.H{"error": "邮箱或密码错误"})
    case errors.Is(err, ErrUserNotFound):
        c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
    default:
        c.JSON(http.StatusInternalServerError, gin.H{"error": "内部错误"})
    }
}

依赖注入:用构造函数 + interface,不用框架

func NewUserService(repo UserRepository) *UserService {
    return &UserService{repo: repo}
}

func NewUserHandler(svc *UserService) *UserHandler {
    return &UserHandler{svc: svc}
}

// main.go 中组装
repo := postgres.NewUserRepository(db)
svc := service.NewUserService(repo)
handler := handler.NewUserHandler(svc)

DTO 与 Model 的转换

// 从 Model 到 Response —— 在这里完成脱敏
func toUserInfoResponse(u *User) *UserInfoResponse {
    return &UserInfoResponse{
        ID:        u.ID,
        Email:     u.Email,
        Nickname:  u.Nickname,
        CreatedAt: u.CreatedAt.Unix(),
    }
}

永远不要把数据库 Model 直接序列化返回给客户端。即使今天 Model 和 Response 字段一模一样,后面加了 password_hashinternal_flags 等字段时,没有 DTO 层就是数据泄露。

Pitfalls

  1. 登录接口暴露用户是否存在 — “用户不存在”和”密码错误”用不同的错误码,攻击者可以枚举有效邮箱。统一返回”凭证无效”
  2. 应用层查重当唯一性保证SELECT + INSERT 不是原子的,并发注册会插入重复数据。必须在 DB 加 UNIQUE 约束,应用层捕获冲突错误
  3. 在 Handler 层写业务逻辑 — 一旦开了头,Service 层就形同虚设,最终 Handler 变成 God Function
  4. JWT 密钥硬编码 — 密钥应从配置/环境变量注入,不要写在代码里
  5. 密码用 MD5/SHA256 — 这些是摘要算法不是密码哈希算法,没有 salt、没有 cost factor。用 golang.org/x/crypto/bcrypt

分享这篇文章:

上一篇
mio(一):Rust 异步生态的 Reactor 基石
下一篇
Go 服务:容量评估与扩容决策