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