一、目录结构
├─mytestProject_mysql
│ ├─config
│ │ └─config.yaml # Viper 配置文件
│ ├─internal # 私有业务代码(防外部引用)
│ │ ├─model # 数据模型层
│ │ │ ├─user.go
│ │ │ └─order.go
│ │ ├─repository # 数据访问层 (DAO)
│ │ │ └─user_repo.go
│ │ ├─service # 业务逻辑层
│ │ │ └─user_service.go
│ │ └─controller # 接口控制层
│ │ └─user_controller.go
│ ├─pkg # 公共工具库
│ │ ├─database # 数据库初始化
│ │ │ └─mysql.go
│ │ └─response # 统一响应封装
│ │ └─response.go
│ ├─logs # 日志目录(配合zap+lumberjack)
│ │ └─app.log
│ ├─main.go # 启动入口与路由注册
│ ├─go.mod
│ └─go.sum
二、GORM核心功能与架构开发
1. 库的选择与安装
ORM框架:
gorm.io/gorm+gorm.io/driver/mysqlWeb框架:
github.com/gin-gonic/gin配置管理:
github.com/spf13/viper日志轮转:
gopkg.in/natefinch/lumberjack.v2(配合zap)
使用以下命令进行安装:
go get -u gorm.io/gorm gorm.io/driver/mysql github.com/gin-gonic/gin github.com/spf13/viper
2. 导入
在对应模块文件中按需导入:
import (
"gorm.io/gorm"
"gorm.io/driver/mysql"
"github.com/gin-gonic/gin"
"github.com/spf13/viper"
)
3. 初始化
进入项目根目录执行:
go mod init mytestProject_mysql
4. 包声明规范
internal/model→package modelinternal/repository→package repositoryinternal/service→package serviceinternal/controller→package controllerpkg/database→package database
三、核心知识点与常见问题
1. 关联关系与预加载
GORM 中“一对多”关系永远在“多”的表中存外键。Preload 用于加载关联数据,但无法用关联表条件过滤主表。
开发中使用:
// model/order.go
type Order struct {
ID int `gorm:"column:id;primaryKey"`
UserID int `gorm:"column:user_id;not null;index"` // 外键+索引
ProductName string `gorm:"column:product_name;size:100"`
Amount float64 `gorm:"column:amount;type:decimal(10,2)"`
}
// model/user.go
type User struct {
ID int `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"`
Age string `gorm:"column:age"`
Telephone string `gorm:"column:telephone"`
Orders []Order `gorm:"foreignKey:UserID"` // ⭐ 显式声明外键
}
主要关注点:
Preload vs Joins:查用户全部订单用
Preload;根据订单条件筛选用户必须用Joins。带条件Preload:既要筛用户又要只加载符合条件的订单时,需
Joins+Preload("Orders", "amount > ?", 5000)+Distinct()。foreignKey标签:虽GORM默认猜测
UserID,但显式声明可避免重构时的隐蔽Bug。N+1问题:永远不要在循环里查关联数据,必须用
Preload或Joins。
2. 事务与Hooks
多个操作必须原子执行时使用事务;自动钩子用于密码加密、UUID生成等通用逻辑。
做法:封装事务工具函数 + 模型钩子
// 事务封装
func Transaction(fn func(tx *gorm.DB) error) error {
return database.DB.Transaction(fn)
}
// BeforeCreate 钩子:插入前自动处理
func (u *User) BeforeCreate(tx *gorm.DB) error {
if u.ID == 0 {
// 自动生成UUID或哈希密码
}
return nil
}
重点:Transaction 内部任何一步返回 error,整个事务自动回滚;Hooks 中不要调用 DB 方法,应使用传入的 tx 参数避免死循环。
3. 配置管理与错误处理
DSN 严禁硬编码;区分 ErrRecordNotFound 与真实数据库异常。
// pkg/database/mysql.go
func Init() error {
logLevel := logger.Warn
if viper.GetString("log.level") == "debug" {
logLevel = logger.Info
}
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local",
viper.GetString("mysql.user"),
viper.GetString("mysql.password"),
viper.GetString("mysql.host"),
viper.GetInt("mysql.port"),
viper.GetString("mysql.dbname"),
)
var err error
DB, err = gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logLevel),
})
return err
}
// 错误判断规范
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrUserNotFound // 业务语义错误
}
return nil, ErrInternalServer // 系统级异常,需记录日志
4. 三层架构分层原则
Controller → Service → Repository,严禁跨层调用。
关键设计:Repository 返回原始 error,Service 转换为业务语义 error,Controller 根据业务error映射HTTP状态码。
四、完整代码
1. config/config.yaml
server:
port: 8080
mysql:
host: 127.0.0.1
port: 3306
user: root
password: your_password
dbname: mytest_db
max_idle_conns: 10
max_open_conns: 100
log:
level: debug # dev用debug,prod用info/warn
2. pkg/database/mysql.go
package database
import (
"fmt"
"github.com/spf13/viper"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
var DB *gorm.DB
func Init() error {
logLevel := logger.Warn
if viper.GetString("log.level") == "debug" {
logLevel = logger.Info
}
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local",
viper.GetString("mysql.user"),
viper.GetString("mysql.password"),
viper.GetString("mysql.host"),
viper.GetInt("mysql.port"),
viper.GetString("mysql.dbname"),
)
var err error
DB, err = gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logLevel),
})
return err
}
3. internal/repository/user_repo.go
package repository
import (
"mytestProject_mysql/internal/model"
"mytestProject_mysql/pkg/database"
)
type UserRepository struct{}
func NewUserRepository() *UserRepository {
return &UserRepository{}
}
func (r *UserRepository) GetByTelephone(phone string) (*model.User, error) {
var user model.User
// 仅负责查询,error原样透传给Service层判断
err := database.DB.Where("telephone = ?", phone).First(&user).Error
if err != nil {
return nil, err
}
return &user, nil
}
func (r *UserRepository) Create(user *model.User) error {
return database.DB.Create(user).Error
}
4. internal/service/user_service.go
package service
import (
"errors"
"mytestProject_mysql/internal/model"
"mytestProject_mysql/internal/repository"
"gorm.io/gorm"
)
var ErrUserNotFound = errors.New("用户不存在")
type UserService struct {
repo *repository.UserRepository
}
func NewUserService() *UserService {
return &UserService{repo: repository.NewUserRepository()}
}
func (s *UserService) GetUserProfile(phone string) (*model.User, error) {
// ⭐ 业务校验放在Service层
if len(phone) != 11 {
return nil, errors.New("手机号格式不正确")
}
user, err := s.repo.GetByTelephone(phone)
if err != nil {
// ⭐ 将DB错误转换为业务语义错误
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrUserNotFound
}
return nil, err // 未知系统错误继续向上抛
}
return user, nil
}
5. internal/controller/user_controller.go
package controller
import (
"net/http"
"github.com/gin-gonic/gin"
"mytestProject_mysql/internal/service"
)
type UserController struct {
svc *service.UserService
}
func NewUserController() *UserController {
return &UserController{svc: service.NewUserService()}
}
func (c *UserController) GetUser(ctx *gin.Context) {
phone := ctx.Param("phone")
user, err := c.svc.GetUserProfile(phone)
if err != nil {
// ⭐ Controller只做错误到HTTP状态码的映射
switch err.Error() {
case "用户不存在":
ctx.JSON(http.StatusNotFound, gin.H{"code": 404, "msg": err.Error()})
case "手机号格式不正确":
ctx.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": err.Error()})
default:
ctx.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": "服务器内部错误"})
}
return
}
ctx.JSON(http.StatusOK, gin.H{"code": 0, "data": user})
}
6. main.go
package main
import (
"fmt"
"os"
"github.com/gin-gonic/gin"
"github.com/spf13/viper"
"mytestProject_mysql/internal/controller"
"mytestProject_mysql/pkg/database"
"mytestProject_mysql/util" // 复用之前整理的zap日志模块
)
func main() {
// 1. 加载配置
viper.SetConfigFile("config/config.yaml")
if err := viper.ReadInConfig(); err != nil {
fmt.Fprintf(os.Stderr, "读取配置失败: %v\n", err)
os.Exit(1)
}
// 2. 初始化日志(复用zap+lumberjack方案)
_, err := util.InitLogger("./logs/app.log")
if err != nil {
fmt.Fprintf(os.Stderr, "初始化日志失败: %v\n", err)
os.Exit(1)
}
defer util.Sync()
// 3. 初始化数据库
if err := database.Init(); err != nil {
util.Error("数据库初始化失败", "error", err)
os.Exit(1)
}
util.Info("Mysql 连接成功")
// 4. 注册路由(依赖注入雏形)
r := gin.Default()
userCtrl := controller.NewUserController()
r.GET("/users/:phone", userCtrl.GetUser)
// 5. 启动服务
port := viper.GetString("server.port")
util.Infof("服务启动于端口: %s", port)
r.Run(":" + port)
}
补充的注意事项
Preload行号偏移问题
封装了Repo层后,GORM日志中的Caller可能指向repo文件而非真实业务调用处。// 修复:在database.Init()中使用自定义Logger并设置 AddCallerSkip(2)全局DB变量是反模式
当前使用全局database.DB便于学习,生产环境推荐通过构造函数注入:// 推荐:依赖注入 type UserRepository struct { db *gorm.DB } func NewUserRepository(db *gorm.DB) *UserRepository { return &UserRepository{db: db} }关联查询性能陷阱
Preload("Orders")会额外发一条SELECT * FROM orders WHERE user_id IN (...),当用户量大时IN列表过长会导致慢查询。// 优化:大数据量改用Joins手动分页 db.Joins("LEFT JOIN orders ON orders.user_id = testTable.id"). Where("orders.amount > ?", 5000). Limit(20).Find(&users)事务嵌套风险
GORM不支持真正的嵌套事务(SAVEPOINT除外)。在Transaction回调内再次调用db.Transaction不会创建新事务,而是加入当前事务。若需独立事务,必须新开连接。单元测试Mock策略
三层架构的核心优势是可测试性。Service层测试时应Mock Repository接口:// 定义接口 type UserRepoInterface interface { GetByTelephone(phone string) (*model.User, error) } // 测试时注入Mock实现,无需连接真实数据库配置热更新
Viper支持WatchConfig()监听配置文件变更,结合 zap.AtomicLevel 可实现运行时动态调整日志级别和数据库连接池大小,无需重启服务。统一响应封装
建议在pkg/response中封装 Success/Error 函数,避免Controller中重复写gin.H:response.Success(ctx, data) response.Error(ctx, http.StatusBadRequest, "参数错误")模型Tag完整性
GORM Tag建议始终显式指定 column、type、size,避免依赖自动推断导致迁移脚本不一致:Amount float64 `gorm:"column:amount;type:decimal(10,2);not null;default:0.00"`软删除注意事项
使用gorm.DeletedAt启用软删除后,所有查询自动追加WHERE deleted_at IS NULL。若需查询已删除数据:db.Unscoped().Find(&users) // 包含已软删除记录生产环境GORM日志级别
生产环境务必设为Warn或Error,Info级别会打印每条SQL,高并发下严重影响性能且产生大量日志。排查问题时通过Viper动态切换为Debug。