Java 分层架构迁移到 Go:controller/service/dao 怎么落?

本文为《Java工程师转Go实战》连载 第 18 篇 / 共 20 篇

上一篇:GMP 与 GC

下一篇:Java CRUD 迁移实战


类比开场

Java 经典分层:

复制代码
Controller → Service → Repository/DAO → DB
     ↓           ↓           ↓
   DTO         Domain      Entity/PO

Go 完全可以 原样迁移这套分层,不必追求奇技淫巧。Java 人最快的上手路径就是:把你熟悉的三层架构用 Go 语法重写一遍。

但 Go 的包机制和 Java 的 class 体系有本质差异------理解这些差异,才能写出 地道且不绕弯 的 Go 项目结构。


一、Go 项目布局标准

社区共识:golang-standards/project-layout

复制代码
your-service/
├── cmd/                     # 入口(可以有多个二进制)
│   └── server/
│       └── main.go          # 组装依赖、启动服务
├── internal/                # 私有代码(Go 编译器禁止外部 import)
│   ├── handler/            # HTTP/gRPC Handler(= Controller)
│   ├── service/            # 业务逻辑
│   ├── repository/         # 数据访问(= DAO)
│   ├── model/              # 数据模型
│   │   ├── entity.go      # DB 实体(= @Entity)
│   │   ├── request.go     # 请求 DTO
│   │   └── response.go    # 响应 DTO
│   └── middleware/         # HTTP/gRPC 中间件
├── pkg/                     # 可被外部引用的公共包
│   ├── response/           # 统一响应
│   └── errcode/            # 错误码定义
├── api/                     # API 定义(proto / OpenAPI)
├── configs/                 # 配置文件
├── scripts/                 # 脚本(build、deploy)
├── migrations/              # 数据库迁移文件
├── docs/                    # 文档
├── Dockerfile
├── Makefile
├── go.mod
└── go.sum

和 Java 项目结构对照

Java (Maven) Go 说明
src/main/java/com/xxx/controller/ internal/handler/ 处理 HTTP 请求
src/main/java/com/xxx/service/ internal/service/ 业务逻辑
src/main/java/com/xxx/dao/ internal/repository/ 数据访问
src/main/java/com/xxx/entity/ internal/model/entity.go DB 实体
src/main/java/com/xxx/dto/ internal/model/request.go DTO
src/main/java/com/xxx/config/ configs/ + internal/config/ 配置
src/main/java/com/xxx/Application.java cmd/server/main.go 启动入口
pom.xml go.mod 依赖管理

二、internal 包的意义

Go 编译器 硬性规定internal/ 目录下的代码只能被其父目录及同级的代码 import。

复制代码
your-service/
├── internal/
│   └── service/user.go    # 只有 your-service 内的代码能 import
└── cmd/server/main.go     # ✅ 可以 import internal/service

other-project/
└── main.go                # ❌ 编译报错:use of internal package not allowed

对标 Java :类似 Java 9 Module System 的 exports,但更简单粗暴------放 internal 就是私有,放 pkg 就是公开。

实践建议 :业务代码全部放 internal,只有真正需要跨项目复用的工具包放 pkg


三、依赖方向(核心原则)

复制代码
┌─────────────────────────────────────────────┐
│                 cmd/main.go                  │  组装所有依赖
│              (Composition Root)              │
└─────────────┬───────────────────────────────┘
              │ import
              ▼
┌─────────────────────────────────────────────┐
│              handler (Controller)             │  绑定参数、调 service、写响应
└─────────────┬───────────────────────────────┘
              │ import
              ▼
┌─────────────────────────────────────────────┐
│              service (Business Logic)         │  业务规则、编排、事务
└─────────────┬───────────────────────────────┘
              │ 依赖接口(不是具体实现)
              ▼
┌─────────────────────────────────────────────┐
│              repository (Data Access)         │  SQL、缓存、第三方 API
└─────────────────────────────────────────────┘

所有层都可以 import:
┌─────────────────────────────────────────────┐
│              model (数据结构)                  │
└─────────────────────────────────────────────┘

关键规则

  1. handler → service → repository:单向依赖,禁止反向
  2. repository 不能 import handler(循环依赖编译报错)
  3. service 层定义 Repository 接口,repository 层提供实现(依赖倒置)
  4. model 包被所有层共享(纯数据结构,无业务逻辑)

四、各层代码示例

Model 层

go 复制代码
// internal/model/entity.go
package model

import "time"

// DB 实体(对标 @Entity)
type User struct {
    ID        int64     `gorm:"primaryKey" json:"id"`
    Name      string    `gorm:"size:64;not null" json:"name"`
    Email     string    `gorm:"size:128;uniqueIndex" json:"email"`
    Password  string    `gorm:"size:128" json:"-"` // json:"-" 不序列化
    Status    int8      `gorm:"default:1" json:"status"`
    CreatedAt time.Time `json:"createdAt"`
    UpdatedAt time.Time `json:"updatedAt"`
}
go 复制代码
// internal/model/request.go
package model

// 请求 DTO(对标 Java 的 XxxReq/XxxDTO)
type CreateUserReq struct {
    Name     string `json:"name" binding:"required,min=1,max=64"`
    Email    string `json:"email" binding:"required,email"`
    Password string `json:"password" binding:"required,min=6,max=32"`
}

type UpdateUserReq struct {
    Name  *string `json:"name" binding:"omitempty,min=1,max=64"`  // 指针:区分"未传"和"空"
    Email *string `json:"email" binding:"omitempty,email"`
}

type ListUserReq struct {
    Keyword  string `form:"keyword"`
    Page     int    `form:"page" binding:"gte=1"`
    PageSize int    `form:"pageSize" binding:"gte=1,lte=100"`
}
go 复制代码
// internal/model/response.go
package model

// 响应 DTO(避免直接返回 entity,防止泄漏字段)
type UserResp struct {
    ID    int64  `json:"id"`
    Name  string `json:"name"`
    Email string `json:"email"`
}

func UserToResp(u *User) *UserResp {
    if u == nil {
        return nil
    }
    return &UserResp{ID: u.ID, Name: u.Name, Email: u.Email}
}

Repository 层

go 复制代码
// internal/service/interfaces.go
// ⚠️ 接口定义在消费方(service 包),不是 repository 包
package service

import (
    "context"
    "your-service/internal/model"
)

type UserRepository interface {
    Create(ctx context.Context, u *model.User) error
    GetByID(ctx context.Context, id int64) (*model.User, error)
    GetByEmail(ctx context.Context, email string) (*model.User, error)
    Update(ctx context.Context, u *model.User) error
    List(ctx context.Context, keyword string, offset, limit int) ([]*model.User, int64, error)
}
go 复制代码
// internal/repository/user_repo.go
package repository

import (
    "context"
    "your-service/internal/model"
    "gorm.io/gorm"
)

type userRepo struct {
    db *gorm.DB
}

func NewUserRepo(db *gorm.DB) *userRepo {
    return &userRepo{db: db}
}

func (r *userRepo) Create(ctx context.Context, u *model.User) error {
    return r.db.WithContext(ctx).Create(u).Error
}

func (r *userRepo) GetByID(ctx context.Context, id int64) (*model.User, error) {
    var u model.User
    err := r.db.WithContext(ctx).First(&u, id).Error
    return &u, err
}

func (r *userRepo) GetByEmail(ctx context.Context, email string) (*model.User, error) {
    var u model.User
    err := r.db.WithContext(ctx).Where("email = ?", email).First(&u).Error
    return &u, err
}

func (r *userRepo) Update(ctx context.Context, u *model.User) error {
    return r.db.WithContext(ctx).Save(u).Error
}

func (r *userRepo) List(ctx context.Context, keyword string, offset, limit int) ([]*model.User, int64, error) {
    var users []*model.User
    var total int64

    query := r.db.WithContext(ctx).Model(&model.User{})
    if keyword != "" {
        query = query.Where("name LIKE ? OR email LIKE ?", "%"+keyword+"%", "%"+keyword+"%")
    }

    if err := query.Count(&total).Error; err != nil {
        return nil, 0, err
    }
    if err := query.Offset(offset).Limit(limit).Order("id DESC").Find(&users).Error; err != nil {
        return nil, 0, err
    }
    return users, total, nil
}

Service 层

go 复制代码
// internal/service/user_service.go
package service

import (
    "context"
    "errors"
    "fmt"

    "golang.org/x/crypto/bcrypt"
    "gorm.io/gorm"
    "your-service/internal/model"
)

var (
    ErrUserNotFound   = errors.New("user not found")
    ErrDuplicateEmail = errors.New("email already exists")
    ErrInvalidName    = errors.New("invalid name")
)

type UserService struct {
    repo UserRepository // 依赖接口,不是具体实现
}

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

func (s *UserService) Create(ctx context.Context, req model.CreateUserReq) (*model.User, error) {
    // 检查邮箱是否已存在
    existing, err := s.repo.GetByEmail(ctx, req.Email)
    if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
        return nil, fmt.Errorf("check email: %w", err)
    }
    if existing != nil && existing.ID > 0 {
        return nil, ErrDuplicateEmail
    }

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

    user := &model.User{
        Name:     req.Name,
        Email:    req.Email,
        Password: string(hashed),
        Status:   1,
    }

    if err := s.repo.Create(ctx, user); err != nil {
        return nil, fmt.Errorf("create user: %w", err)
    }
    return user, nil
}

func (s *UserService) GetByID(ctx context.Context, id int64) (*model.User, error) {
    user, err := s.repo.GetByID(ctx, id)
    if err != nil {
        if errors.Is(err, gorm.ErrRecordNotFound) {
            return nil, ErrUserNotFound
        }
        return nil, fmt.Errorf("get user: %w", err)
    }
    return user, nil
}

func (s *UserService) List(ctx context.Context, req model.ListUserReq) ([]*model.User, int64, error) {
    offset := (req.Page - 1) * req.PageSize
    return s.repo.List(ctx, req.Keyword, offset, req.PageSize)
}

Handler 层

go 复制代码
// internal/handler/user_handler.go
package handler

import (
    "errors"
    "net/http"
    "strconv"

    "github.com/gin-gonic/gin"
    "your-service/internal/model"
    "your-service/internal/service"
    "your-service/pkg/response"
)

type UserHandler struct {
    svc *service.UserService
}

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

func (h *UserHandler) Create(c *gin.Context) {
    var req model.CreateUserReq
    if err := c.ShouldBindJSON(&req); err != nil {
        response.BadRequest(c, err.Error())
        return
    }

    user, err := h.svc.Create(c.Request.Context(), req)
    if err != nil {
        h.handleError(c, err)
        return
    }
    response.Created(c, model.UserToResp(user))
}

func (h *UserHandler) Get(c *gin.Context) {
    id, err := strconv.ParseInt(c.Param("id"), 10, 64)
    if err != nil {
        response.BadRequest(c, "invalid id")
        return
    }

    user, err := h.svc.GetByID(c.Request.Context(), id)
    if err != nil {
        h.handleError(c, err)
        return
    }
    response.OK(c, model.UserToResp(user))
}

func (h *UserHandler) List(c *gin.Context) {
    var req model.ListUserReq
    req.Page = 1
    req.PageSize = 20
    if err := c.ShouldBindQuery(&req); err != nil {
        response.BadRequest(c, err.Error())
        return
    }

    users, total, err := h.svc.List(c.Request.Context(), req)
    if err != nil {
        h.handleError(c, err)
        return
    }

    list := make([]*model.UserResp, 0, len(users))
    for _, u := range users {
        list = append(list, model.UserToResp(u))
    }
    response.OKPage(c, list, total, req.Page, req.PageSize)
}

func (h *UserHandler) handleError(c *gin.Context, err error) {
    switch {
    case errors.Is(err, service.ErrUserNotFound):
        response.NotFound(c, "用户不存在")
    case errors.Is(err, service.ErrDuplicateEmail):
        response.BadRequest(c, "邮箱已被注册")
    default:
        response.ServerError(c)
    }
}

五、main 组装(对标 Spring IoC 容器)

go 复制代码
// cmd/server/main.go
package main

import (
    "fmt"
    "os"
    "os/signal"
    "syscall"
    "context"
    "net/http"
    "time"

    "github.com/gin-gonic/gin"
    "your-service/internal/config"
    "your-service/internal/handler"
    "your-service/internal/middleware"
    "your-service/internal/repository"
    "your-service/internal/service"
)

func main() {
    // 加载配置
    cfg := config.Load()

    // 初始化基础设施
    db := initDB(cfg.Database)
    rdb := initRedis(cfg.Redis)
    logger := initLogger(cfg.Log)

    // ====== 依赖组装(= Spring 容器的工作)======
    userRepo := repository.NewUserRepo(db)
    userSvc := service.NewUserService(userRepo)
    userHandler := handler.NewUserHandler(userSvc)

    orderRepo := repository.NewOrderRepo(db)
    orderSvc := service.NewOrderService(orderRepo, rdb)
    orderHandler := handler.NewOrderHandler(orderSvc)

    // ====== 路由注册 ======
    r := gin.New()
    r.Use(
        middleware.Logger(logger),
        middleware.Recovery(logger),
        middleware.RequestID(),
        middleware.CORS(),
    )

    api := r.Group("/api/v1")
    {
        // 公开路由
        api.POST("/register", userHandler.Create)

        // 需要认证的路由
        auth := api.Group("", middleware.Auth(cfg.JWT.Secret))
        auth.GET("/users/:id", userHandler.Get)
        auth.GET("/users", userHandler.List)
        auth.POST("/orders", orderHandler.Create)
    }

    // ====== 优雅启动与关闭 ======
    srv := &http.Server{
        Addr:         fmt.Sprintf(":%d", cfg.Server.Port),
        Handler:      r,
        ReadTimeout:  cfg.Server.ReadTimeout,
        WriteTimeout: cfg.Server.WriteTimeout,
    }

    go func() {
        logger.Info("server starting", "port", cfg.Server.Port)
        if err := srv.ListenAndServe(); err != http.ErrServerClosed {
            logger.Error("listen failed", "error", err)
            os.Exit(1)
        }
    }()

    // 等待退出信号
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit
    logger.Info("shutting down...")

    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    srv.Shutdown(ctx)
    logger.Info("server stopped")
}

大型项目:用 google/wire 自动生成依赖注入

go 复制代码
// wire.go(定义依赖关系,wire 工具自动生成组装代码)
//go:build wireinject

package main

import (
    "github.com/google/wire"
    "your-service/internal/handler"
    "your-service/internal/repository"
    "your-service/internal/service"
)

func InitializeApp(cfg *config.Config) (*App, error) {
    wire.Build(
        initDB,
        initRedis,
        repository.NewUserRepo,
        repository.NewOrderRepo,
        service.NewUserService,
        service.NewOrderService,
        handler.NewUserHandler,
        handler.NewOrderHandler,
        NewApp,
    )
    return nil, nil
}
bash 复制代码
wire ./cmd/server/
# 生成 wire_gen.go,包含完整的依赖组装代码

对标 Spring 的 @Autowired,但是 编译期 生成,没有运行时反射开销。


六、和 DDD 的关系

Java DDD 概念 Go 实现 说明
Aggregate Root 大 struct + 包私有字段 用包边界保护不变式
Domain Service service 包中的函数/struct
Repository (接口) service 包定义的 interface 消费方定义
Infrastructure repository 包 + 外部 SDK
Application Service handler + service 编排
Value Object 小 struct(不可变) Go 没有 final
Domain Event channel / 消息队列
Anti-Corruption Layer adapter 包 隔离第三方

实际的 DDD 目录(大型项目)

复制代码
internal/
├── domain/
│   ├── user/
│   │   ├── entity.go        # 聚合根
│   │   ├── repository.go    # 仓储接口
│   │   ├── service.go       # 领域服务
│   │   └── event.go         # 领域事件
│   └── order/
│       ├── entity.go
│       ├── repository.go
│       └── service.go
├── application/              # 应用层(用例编排)
│   ├── user_usecase.go
│   └── order_usecase.go
├── interfaces/               # 接口层(HTTP/gRPC/MQ)
│   ├── http/
│   │   ├── user_handler.go
│   │   └── order_handler.go
│   └── grpc/
├── infrastructure/           # 基础设施
│   ├── persistence/         # 仓储实现
│   │   ├── mysql_user_repo.go
│   │   └── redis_cache.go
│   └── external/            # 外部服务
│       └── payment_gateway.go

注意:大多数项目不需要 DDD。简单三层够用就别过度设计。


七、常见陷阱

1. 循环依赖(Go 直接编译报错)

go 复制代码
// ❌ service 和 handler 互相 import → 编译失败
// package service imports package handler
// package handler imports package service

// ✅ 解决:
// 方法 1:接口定义在消费方
// 方法 2:公共类型提取到 model 包
// 方法 3:拆分更细的包

2. 包层级太深

go 复制代码
// ❌ Java 习惯:com.company.project.module.submodule.XXX
// internal/domain/user/service/impl/user_service_impl.go → 过度

// ✅ Go 习惯:扁平
// internal/service/user_service.go → 够了

3. 一个包一个文件 vs 一个包多个文件

go 复制代码
// Go 的包不是"一个文件一个类"
// 一个包下可以有多个 .go 文件,它们共享包级变量和类型

// ✅ 合理的文件划分
internal/service/
├── user_service.go      // UserService
├── order_service.go     // OrderService
├── interfaces.go        // 接口定义
└── errors.go            // 包级错误定义

4. 不要 interface pollution

go 复制代码
// ❌ 每个 struct 都定义一个 interface(Java 习惯)
type UserServiceInterface interface {
    Create(...)
    Get(...)
    Update(...)
    Delete(...)
}

// ✅ 只在需要 mock 或多态时才定义接口
// repository 需要接口(因为 service 测试要 mock)
// service 通常不需要接口(handler 直接依赖具体 struct)

八、Makefile:项目命令统一入口

makefile 复制代码
.PHONY: build run test lint migrate

# 变量
APP_NAME := user-service
MAIN := ./cmd/server

build:
	CGO_ENABLED=0 go build -o bin/$(APP_NAME) $(MAIN)

run:
	go run $(MAIN)

test:
	go test -race -short ./...

test-cover:
	go test -race -coverprofile=coverage.out ./...
	go tool cover -func=coverage.out | tail -1

lint:
	golangci-lint run ./...

migrate-up:
	migrate -database "$(DB_DSN)" -path migrations up

migrate-down:
	migrate -database "$(DB_DSN)" -path migrations down 1

proto:
	buf generate

wire:
	wire ./cmd/server/

docker:
	docker build -t $(APP_NAME):latest .

dev:
	air

📦 面试追问

  1. handler 能直接调 repo 吗?

    小 demo 可以;生产应经 service,便于添加业务规则、事务和测试。handler 只做参数绑定+响应序列化。

  2. DTO 和 Entity 要分吗?

    建议分。CreateUserReq(不含 ID、时间)/ User(DB 字段完整)/ UserResp(不含密码)。和 Java 一样防止字段泄漏和解耦变更。

  3. go-zero / Kratos 结构不同?

    go-zero 是 handler→logic→svc+model;Kratos 是 server→service→biz→data。本质仍是 handler-service-repo 三层,只是命名和代码生成约定不同。

  4. 什么时候用 wire?

    依赖超过 10 个、多个入口(HTTP+gRPC+consumer)时手动组装太繁琐,wire 编译期生成保证类型安全。小项目手动 New() 完全够用。

  5. Go 怎么做 @Transactional?

    没有注解。用 db.Transaction(func(tx *gorm.DB) error { ... }) 手动包装。如需跨 repository 事务,在 service 层传 tx 或用 unit of work 模式。


💡 一句话总结

Spring 那套三层别扔------换 Go 语法重写一遍,controller→handler,service→service,dao→repository,@Autowired→构造函数注入,加上 internal 限制可见性。比你强行上 DDD 框架更接地气,也是团队最快达成共识的方式。


建议标签Golang Java 项目结构 分层架构 Gin DDD wire

相关推荐
LONGZETECH2 小时前
工业实训仿真设计实践:电机拆装软件的 DAG 流程建模、工具精度分级与数据体系搭建
大数据·算法·unity·架构·汽车
我是人✓2 小时前
SQL主键与外键
java·数据库
圣光SG2 小时前
Java操作题练习(二)
java·开发语言·python
蜡台2 小时前
Android WebView 设计指南
android·java·kotlin
AI_paid_community2 小时前
机械研发人员如何使用 Claude?
java·vue.js
MacroZheng2 小时前
同事:“Claude Code都能自动写代码了,还要什么Spec Coding?” 我反问:“屎山代码你来维护?”
java·人工智能·后端
未秃头的程序猿2 小时前
JDK 26的Value Class,我做了个性能测试,结果出乎意料
java·后端·面试
Zane19942 小时前
volatile / synchronized / final:三大特性(原子性/可见性/有序性)到底谁保证了什么?
java·后端
巴勒个啦2 小时前
从需求到上线:记录一次完全由 AI 辅助完成的小产品全流程
java·前端
名字还没想好☜3 小时前
Spring @Async 不生效排查:自调用失效、默认线程池坑与异步方法里的异常去哪了
java·python·spring·异步