GORM数据库操作:从Hibernate/MyBatis到GORM

1. 文档信息

  • 阶段:第2天:Web框架与数据库
  • 预计学习时间:4小时
  • 前置知识
    • Go语言基础(结构体、接口、错误处理)
    • Gin框架基础
    • Java Hibernate或MyBatis使用经验
    • SQL基础
  • 学习目标
    • 理解GORM与Hibernate/MyBatis的设计哲学差异
    • 掌握GORM模型定义和数据库连接配置
    • 掌握CRUD操作和复杂查询构建
    • 掌握关联关系和事务处理
    • 能够使用GORM构建完整的数据访问层

2. 引言

2.1 为什么需要学习GORM?

作为Java开发者,你熟悉Hibernate的注解驱动和MyBatis的SQL映射:

// Java Hibernate - 熟悉的注解方式
@Entity
@Table(name = "users")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "username", nullable = false, length = 50)
    private String username;

    @OneToMany(mappedBy = "user", cascade = CascadeType.ALL)
    private List<Post> posts;
}

Go的GORM采用结构体标签(struct tag)替代注解,更简洁直观:

// Go GORM - 结构体标签方式
type User struct {
    gorm.Model                          // 内嵌ID/CreatedAt/UpdatedAt/DeletedAt
    Username string `gorm:"size:50;not null;uniqueIndex"`
    Email    string `gorm:"size:100;not null;uniqueIndex"`
    Posts    []Post `gorm:"foreignKey:UserID"` // 关联关系
}

2.2 本章学习内容

  • GORM vs Hibernate/MyBatis 设计哲学对比
  • 模型定义:struct tag vs @Entity注解
  • 数据库连接配置
  • CRUD操作对比
  • 复杂查询构建
  • 关联关系(HasOne/HasMany/BelongsTo)
  • 事务处理
  • 数据库迁移
  • 工程实践:Repository模式 + 表驱动测试

2.3 知识导图

GORM

模型定义

struct tag

gorm.Model

自定义表名

CRUD操作

Create

Read/Query

Update

Delete

关联关系

HasOne

HasMany

BelongsTo

ManyToMany

高级特性

事务

钩子Hook

软删除

AutoMigrate

工程实践

Repository模式

错误处理

连接池配置

表驱动测试


3. 核心概念

3.1 GORM vs Hibernate/MyBatis:ORM设计哲学

定义和解释

GORM 是Go语言最流行的ORM框架,采用"约定优于配置"原则,通过结构体标签定义数据库映射,提供链式API构建查询。

Hibernate 是Java重量级ORM,通过注解或XML配置实现对象关系映射,支持HQL查询语言。

MyBatis 是Java半ORM框架,通过XML或注解映射SQL,给开发者更多SQL控制权。

核心差异对比
特性 Hibernate MyBatis GORM
映射方式 注解/XML XML/注解 struct tag
SQL控制 自动生成 手写SQL 自动+可自定义
学习曲线 陡峭 中等 平缓
性能 较重 轻量 轻量
懒加载 支持 不支持 Preload
事务 @Transactional 手动/注解 db.Transaction
模型定义对比

Java Hibernate

@Entity
@Table(name = "users")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true, length = 50)
    private String username;

    @Column(nullable = false, unique = true)
    private String email;

    @CreationTimestamp
    private LocalDateTime createdAt;

    @UpdateTimestamp
    private LocalDateTime updatedAt;

    @OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
    private List<Post> posts = new ArrayList<>();
}

Go GORM

// models/user.go
package models

import "gorm.io/gorm"

// User 用户模型,gorm.Model内嵌了ID/CreatedAt/UpdatedAt/DeletedAt
type User struct {
    gorm.Model                                    // 等价于Hibernate的@Id+@CreationTimestamp
    Username string `gorm:"size:50;not null;uniqueIndex" json:"username"`
    Email    string `gorm:"size:100;not null;uniqueIndex" json:"email"`
    Password string `gorm:"size:255;not null" json:"-"` // json:"-"不序列化密码
    Posts    []Post `gorm:"foreignKey:UserID"`           // HasMany关联
}

// Post 文章模型
type Post struct {
    gorm.Model
    Title   string `gorm:"size:200;not null" json:"title"`
    Content string `gorm:"type:text" json:"content"`
    UserID  uint   `gorm:"not null;index" json:"user_id"` // 外键
    User    User   `gorm:"foreignKey:UserID"`              // BelongsTo关联
}

3.2 数据库连接配置

Java Spring Boot DataSource
// application.properties
spring.datasource.url=jdbc:mysql://localhost:3306/mydb
spring.datasource.username=root
spring.datasource.password=password
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true

// 连接池配置(HikariCP)
spring.datasource.hikari.maximum-pool-size=10
spring.datasource.hikari.minimum-idle=5
spring.datasource.hikari.connection-timeout=30000
Go GORM连接配置
// config/database.go
package config

import (
    "fmt"
    "log"
    "os"
    "time"

    "gorm.io/driver/mysql"
    "gorm.io/gorm"
    "gorm.io/gorm/logger"
)

// InitDB 初始化数据库连接
func InitDB() *gorm.DB {
    dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
        os.Getenv("DB_USER"),     // 数据库用户名
        os.Getenv("DB_PASSWORD"), // 数据库密码
        os.Getenv("DB_HOST"),     // 数据库主机
        os.Getenv("DB_PORT"),     // 数据库端口
        os.Getenv("DB_NAME"),     // 数据库名称
    )

    // 配置GORM日志(类似spring.jpa.show-sql=true)
    newLogger := logger.New(
        log.New(os.Stdout, "\r\n", log.LstdFlags),
        logger.Config{
            SlowThreshold: time.Second,  // 慢查询阈值
            LogLevel:      logger.Info,  // 日志级别
            Colorful:      true,         // 彩色输出
        },
    )

    db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
        Logger: newLogger,
    })
    if err != nil {
        log.Fatal("数据库连接失败:", err)
    }

    // 配置连接池(类似HikariCP配置)
    sqlDB, _ := db.DB()
    sqlDB.SetMaxOpenConns(10)           // 最大连接数
    sqlDB.SetMaxIdleConns(5)            // 最大空闲连接数
    sqlDB.SetConnMaxLifetime(time.Hour) // 连接最大存活时间

    return db
}

3.3 AutoMigrate vs Flyway/Liquibase

Java Flyway迁移
// src/main/resources/db/migration/V1__Create_users_table.sql
CREATE TABLE users (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    username VARCHAR(50) NOT NULL UNIQUE,
    email VARCHAR(100) NOT NULL UNIQUE,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
Go GORM AutoMigrate
// 自动迁移:根据struct定义创建/更新表结构
// 类似Hibernate的ddl-auto=update,但更安全(只增不删)
func AutoMigrate(db *gorm.DB) error {
    return db.AutoMigrate(
        &User{},    // 自动创建users表
        &Post{},    // 自动创建posts表
        &Comment{}, // 自动创建comments表
    )
}

// 输出(首次运行):
// CREATE TABLE `users` (
//   `id` bigint unsigned AUTO_INCREMENT,
//   `created_at` datetime(3),
//   `updated_at` datetime(3),
//   `deleted_at` datetime(3),
//   `username` varchar(50) NOT NULL,
//   `email` varchar(100) NOT NULL,
//   PRIMARY KEY (`id`),
//   UNIQUE INDEX `idx_users_username` (`username`),
//   UNIQUE INDEX `idx_users_email` (`email`),
//   INDEX `idx_users_deleted_at` (`deleted_at`)
// )

⚠️ 注意:AutoMigrate只会新增列和索引,不会删除列。生产环境建议使用golang-migrate做版本化迁移。


4. 代码示例详解

4.1 基础示例

示例1:创建记录(Create vs save())

Java JPA

// Java JPA Repository
userRepository.save(new User("john", "john@example.com"));

Go GORM

package main

import (
    "fmt"
    "gorm.io/driver/sqlite"
    "gorm.io/gorm"
)

type User struct {
    gorm.Model
    Username string `gorm:"size:50;not null;uniqueIndex"`
    Email    string `gorm:"size:100;not null;uniqueIndex"`
    Age      int
}

func main() {
    // 使用SQLite演示(生产用MySQL/PostgreSQL)
    db, _ := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})
    db.AutoMigrate(&User{})

    // 方式1:创建单条记录
    user := User{Username: "john", Email: "john@example.com", Age: 25}
    result := db.Create(&user) // 传指针,GORM会回填ID
    fmt.Printf("创建用户ID: %d, 影响行数: %d\n", user.ID, result.RowsAffected)

    // 方式2:批量创建(比循环调用Create高效)
    users := []User{
        {Username: "alice", Email: "alice@example.com", Age: 30},
        {Username: "bob", Email: "bob@example.com", Age: 28},
    }
    db.CreateInBatches(users, 100) // 每批100条

    // 方式3:使用Map创建(不触发钩子)
    db.Model(&User{}).Create(map[string]interface{}{
        "username": "charlie",
        "email":    "charlie@example.com",
    })
}

// 输出:
// 创建用户ID: 1, 影响行数: 1
示例2:查询记录(Find vs findById)

Java JPA

// 按ID查询
Optional<User> user = userRepository.findById(1L);
// 查询所有
List<User> users = userRepository.findAll();
// 条件查询
List<User> adults = userRepository.findByAgeGreaterThan(18);

Go GORM

package main

import (
    "fmt"
    "gorm.io/driver/sqlite"
    "gorm.io/gorm"
)

func main() {
    db, _ := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})

    // 按主键查询(类似findById)
    var user User
    result := db.First(&user, 1) // SELECT * FROM users WHERE id=1 LIMIT 1
    if result.Error != nil {
        if result.Error == gorm.ErrRecordNotFound {
            fmt.Println("用户不存在")
        }
        return
    }
    fmt.Printf("找到用户: %s\n", user.Username)

    // 查询所有(类似findAll)
    var users []User
    db.Find(&users) // SELECT * FROM users WHERE deleted_at IS NULL

    // 条件查询(类似findByAgeGreaterThan)
    var adults []User
    db.Where("age > ?", 18).Find(&adults)

    // 链式查询(类似Criteria API)
    var result2 []User
    db.Where("username LIKE ?", "%john%").
        Where("age >= ?", 20).
        Order("created_at DESC").
        Limit(10).
        Offset(0).
        Find(&result2)

    fmt.Printf("成年用户数: %d\n", len(adults))
}

// 输出:
// 找到用户: john
// 成年用户数: 3
示例3:更新记录(Save vs save())

Java JPA

User user = userRepository.findById(1L).orElseThrow();
user.setUsername("john_updated");
userRepository.save(user); // 全量更新

Go GORM

package main

import (
    "fmt"
    "gorm.io/driver/sqlite"
    "gorm.io/gorm"
)

func main() {
    db, _ := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})

    // 方式1:Save全量更新(类似JPA的save)
    var user User
    db.First(&user, 1)
    user.Username = "john_updated"
    db.Save(&user) // UPDATE users SET username=?, updated_at=? WHERE id=?

    // 方式2:Updates部分更新(推荐,类似@DynamicUpdate)
    db.Model(&user).Updates(User{Username: "john_v2", Age: 26})
    // 只更新非零值字段:UPDATE users SET username=?, age=? WHERE id=?

    // 方式3:Update单字段
    db.Model(&user).Update("username", "john_v3")

    // 方式4:使用Map更新(可以更新零值)
    db.Model(&user).Updates(map[string]interface{}{
        "username": "john_final",
        "age":      0, // Map方式可以更新零值
    })

    fmt.Printf("更新后用户名: %s\n", user.Username)
}

// 输出:
// 更新后用户名: john_final

⚠️ 常见陷阱Updates(struct)会忽略零值字段(如Age: 0不会更新),使用Updates(map)可以更新零值。

示例4:删除记录(软删除 vs 硬删除)

Java JPA

userRepository.deleteById(1L); // 硬删除
// 软删除需要手动实现@Where注解

Go GORM

package main

import (
    "fmt"
    "gorm.io/driver/sqlite"
    "gorm.io/gorm"
)

func main() {
    db, _ := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})

    var user User
    db.First(&user, 1)

    // 软删除(gorm.Model包含DeletedAt字段,自动启用软删除)
    db.Delete(&user) // UPDATE users SET deleted_at=NOW() WHERE id=1
    // 查询时自动过滤软删除记录:WHERE deleted_at IS NULL

    // 查询包含软删除的记录
    var allUsers []User
    db.Unscoped().Find(&allUsers) // 不过滤deleted_at

    // 硬删除(永久删除)
    db.Unscoped().Delete(&user) // DELETE FROM users WHERE id=1

    // 按条件删除
    db.Where("age < ?", 18).Delete(&User{})

    fmt.Printf("总用户数(含软删除): %d\n", len(allUsers))
}

// 输出:
// 总用户数(含软删除): 4
示例5:事务处理(Transaction vs @Transactional)

Java Spring

@Service
public class UserService {
    @Transactional // 声明式事务
    public void transferMoney(Long fromId, Long toId, BigDecimal amount) {
        User from = userRepository.findById(fromId).orElseThrow();
        User to = userRepository.findById(toId).orElseThrow();
        from.setBalance(from.getBalance().subtract(amount));
        to.setBalance(to.getBalance().add(amount));
        userRepository.save(from);
        userRepository.save(to);
        // 抛出异常自动回滚
    }
}

Go GORM

package main

import (
    "errors"
    "fmt"
    "gorm.io/driver/sqlite"
    "gorm.io/gorm"
)

func transferMoney(db *gorm.DB, fromID, toID uint, amount float64) error {
    // db.Transaction自动处理提交和回滚(类似@Transactional)
    return db.Transaction(func(tx *gorm.DB) error {
        var from, to User

        // 使用tx(事务对象)而非db
        if err := tx.First(&from, fromID).Error; err != nil {
            return err // 返回error自动回滚
        }
        if err := tx.First(&to, toID).Error; err != nil {
            return err
        }

        if from.Age < 18 {
            return errors.New("未成年用户不能转账") // 业务错误也会回滚
        }

        // 更新余额
        if err := tx.Model(&from).Update("age", from.Age-1).Error; err != nil {
            return err
        }
        if err := tx.Model(&to).Update("age", to.Age+1).Error; err != nil {
            return err
        }

        return nil // 返回nil自动提交
    })
}

func main() {
    db, _ := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})
    err := transferMoney(db, 1, 2, 100.0)
    if err != nil {
        fmt.Println("转账失败:", err)
    } else {
        fmt.Println("转账成功")
    }
}

4.2 进阶示例

示例6:关联关系 - HasMany(一对多)

Java JPA

@Entity
public class User {
    @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    private List<Post> posts;
}
// 查询时需要JOIN FETCH避免N+1问题
@Query("SELECT u FROM User u LEFT JOIN FETCH u.posts WHERE u.id = :id")
Optional<User> findByIdWithPosts(@Param("id") Long id);

Go GORM

package main

import (
    "fmt"
    "gorm.io/driver/sqlite"
    "gorm.io/gorm"
)

type Post struct {
    gorm.Model
    Title  string `gorm:"size:200;not null"`
    UserID uint   `gorm:"not null;index"` // 外键
}

type User struct {
    gorm.Model
    Username string `gorm:"size:50;not null;uniqueIndex"`
    Posts    []Post `gorm:"foreignKey:UserID"` // HasMany
}

func main() {
    db, _ := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})
    db.AutoMigrate(&User{}, &Post{})

    // 创建用户和文章
    user := User{
        Username: "john",
        Posts: []Post{ // 关联创建
            {Title: "Go入门"},
            {Title: "Go并发编程"},
        },
    }
    db.Create(&user)

    // 预加载关联(解决N+1问题,类似JOIN FETCH)
    var userWithPosts User
    db.Preload("Posts").First(&userWithPosts, user.ID)
    fmt.Printf("用户 %s 有 %d 篇文章\n", userWithPosts.Username, len(userWithPosts.Posts))

    // 条件预加载
    var userWithRecentPosts User
    db.Preload("Posts", "created_at > ?", "2026-01-01").First(&userWithRecentPosts, user.ID)
}

// 输出:
// 用户 john 有 2 篇文章
示例7:复杂查询构建(链式API vs JPQL)

Java JPQL

@Query("SELECT u FROM User u WHERE u.age >= :minAge AND u.username LIKE :keyword ORDER BY u.createdAt DESC")
Page<User> searchUsers(@Param("minAge") int minAge, @Param("keyword") String keyword, Pageable pageable);

Go GORM链式查询

package main

import (
    "fmt"
    "gorm.io/driver/sqlite"
    "gorm.io/gorm"
)

// UserQuery 查询参数结构体(工程实践:封装查询条件)
type UserQuery struct {
    Keyword string
    MinAge  int
    Page    int
    Size    int
}

func searchUsers(db *gorm.DB, q UserQuery) ([]User, int64, error) {
    var users []User
    var total int64

    // 构建基础查询
    query := db.Model(&User{})

    // 动态条件(类似Criteria API)
    if q.Keyword != "" {
        query = query.Where("username LIKE ?", "%"+q.Keyword+"%")
    }
    if q.MinAge > 0 {
        query = query.Where("age >= ?", q.MinAge)
    }

    // 先查总数
    query.Count(&total)

    // 分页查询
    offset := (q.Page - 1) * q.Size
    err := query.Order("created_at DESC").
        Limit(q.Size).
        Offset(offset).
        Find(&users).Error

    return users, total, err
}

func main() {
    db, _ := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})

    users, total, err := searchUsers(db, UserQuery{
        Keyword: "john",
        MinAge:  18,
        Page:    1,
        Size:    10,
    })
    if err != nil {
        fmt.Println("查询失败:", err)
        return
    }
    fmt.Printf("共 %d 条,当前页 %d 条\n", total, len(users))
}
示例8:GORM钩子(Hooks vs @PrePersist)

Java JPA

@Entity
public class User {
    @PrePersist
    public void prePersist() {
        this.password = BCrypt.hashpw(this.password, BCrypt.gensalt());
    }
}

Go GORM Hooks

package main

import (
    "fmt"
    "golang.org/x/crypto/bcrypt"
    "gorm.io/driver/sqlite"
    "gorm.io/gorm"
)

type User struct {
    gorm.Model
    Username string `gorm:"size:50;not null;uniqueIndex"`
    Password string `gorm:"size:255;not null"`
    Email    string `gorm:"size:100;not null;uniqueIndex"`
}

// BeforeCreate 创建前钩子(类似@PrePersist)
func (u *User) BeforeCreate(tx *gorm.DB) error {
    // 自动加密密码
    hashed, err := bcrypt.GenerateFromPassword([]byte(u.Password), bcrypt.DefaultCost)
    if err != nil {
        return err
    }
    u.Password = string(hashed)
    return nil
}

// AfterCreate 创建后钩子(类似@PostPersist)
func (u *User) AfterCreate(tx *gorm.DB) error {
    fmt.Printf("新用户创建成功: ID=%d, Username=%s\n", u.ID, u.Username)
    return nil
}

// BeforeUpdate 更新前钩子
func (u *User) BeforeUpdate(tx *gorm.DB) error {
    // 如果密码被修改,重新加密
    if tx.Statement.Changed("Password") {
        hashed, _ := bcrypt.GenerateFromPassword([]byte(u.Password), bcrypt.DefaultCost)
        tx.Statement.SetColumn("Password", string(hashed))
    }
    return nil
}

func main() {
    db, _ := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})
    db.AutoMigrate(&User{})

    user := User{Username: "john", Password: "123456", Email: "john@example.com"}
    db.Create(&user) // 自动触发BeforeCreate加密密码
}

// 输出:
// 新用户创建成功: ID=1, Username=john
示例9:原生SQL查询

Java MyBatis

@Select("SELECT * FROM users WHERE age BETWEEN #{minAge} AND #{maxAge}")
List<User> findByAgeRange(@Param("minAge") int minAge, @Param("maxAge") int maxAge);

Go GORM原生SQL

package main

import (
    "fmt"
    "gorm.io/driver/sqlite"
    "gorm.io/gorm"
)

func main() {
    db, _ := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})

    // 原生SQL查询(类似MyBatis的@Select)
    var users []User
    db.Raw("SELECT * FROM users WHERE age BETWEEN ? AND ? AND deleted_at IS NULL",
        18, 30).Scan(&users)

    // 原生SQL执行(INSERT/UPDATE/DELETE)
    db.Exec("UPDATE users SET age = age + 1 WHERE username = ?", "john")

    // 扫描到自定义结构体(类似MyBatis的ResultMap)
    type UserStats struct {
        Username  string
        PostCount int
    }
    var stats []UserStats
    db.Raw(`
        SELECT u.username, COUNT(p.id) as post_count
        FROM users u
        LEFT JOIN posts p ON p.user_id = u.id AND p.deleted_at IS NULL
        WHERE u.deleted_at IS NULL
        GROUP BY u.id, u.username
        ORDER BY post_count DESC
    `).Scan(&stats)

    fmt.Printf("查询到 %d 个用户统计\n", len(stats))
    for _, s := range stats {
        fmt.Printf("  %s: %d篇文章\n", s.Username, s.PostCount)
    }
}
示例10:Repository模式(工程实践)
// internal/repository/user_repository.go
package repository

import (
    "gorm.io/gorm"
    "myapp/internal/model"
)

// UserRepository 用户仓库接口(依赖倒置原则)
type UserRepository interface {
    Create(user *model.User) error
    FindByID(id uint) (*model.User, error)
    FindByUsername(username string) (*model.User, error)
    Update(user *model.User) error
    Delete(id uint) error
    List(page, size int) ([]model.User, int64, error)
}

// userRepositoryImpl 用户仓库实现
type userRepositoryImpl struct {
    db *gorm.DB
}

// NewUserRepository 构造函数(依赖注入)
func NewUserRepository(db *gorm.DB) UserRepository {
    return &userRepositoryImpl{db: db}
}

func (r *userRepositoryImpl) Create(user *model.User) error {
    return r.db.Create(user).Error
}

func (r *userRepositoryImpl) FindByID(id uint) (*model.User, error) {
    var user model.User
    err := r.db.Preload("Posts").First(&user, id).Error
    if err != nil {
        return nil, err
    }
    return &user, nil
}

func (r *userRepositoryImpl) FindByUsername(username string) (*model.User, error) {
    var user model.User
    err := r.db.Where("username = ?", username).First(&user).Error
    if err != nil {
        return nil, err
    }
    return &user, nil
}

func (r *userRepositoryImpl) Update(user *model.User) error {
    return r.db.Save(user).Error
}

func (r *userRepositoryImpl) Delete(id uint) error {
    return r.db.Delete(&model.User{}, id).Error
}

func (r *userRepositoryImpl) List(page, size int) ([]model.User, int64, error) {
    var users []model.User
    var total int64
    r.db.Model(&model.User{}).Count(&total)
    err := r.db.Offset((page - 1) * size).Limit(size).Find(&users).Error
    return users, total, err
}

4.3 高级应用

示例11:多对多关系(ManyToMany)
package main

import (
    "fmt"
    "gorm.io/driver/sqlite"
    "gorm.io/gorm"
)

type Tag struct {
    gorm.Model
    Name  string  `gorm:"size:50;not null;uniqueIndex"`
    Posts []Post2 `gorm:"many2many:post_tags;"` // 多对多
}

type Post2 struct {
    gorm.Model
    Title  string `gorm:"size:200;not null"`
    UserID uint
    Tags   []Tag `gorm:"many2many:post_tags;"` // 多对多,自动创建中间表
}

func main() {
    db, _ := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})
    db.AutoMigrate(&Tag{}, &Post2{})

    // 创建标签
    goTag := Tag{Name: "Go"}
    webTag := Tag{Name: "Web"}
    db.Create(&goTag)
    db.Create(&webTag)

    // 创建文章并关联标签
    post := Post2{
        Title: "Go Web开发",
        Tags:  []Tag{goTag, webTag}, // 关联多个标签
    }
    db.Create(&post)

    // 预加载多对多关联
    var postWithTags Post2
    db.Preload("Tags").First(&postWithTags, post.ID)
    fmt.Printf("文章《%s》的标签: ", postWithTags.Title)
    for _, tag := range postWithTags.Tags {
        fmt.Printf("%s ", tag.Name)
    }
    fmt.Println()

    // 添加关联
    db.Model(&post).Association("Tags").Append(&Tag{Name: "Backend"})

    // 删除关联(不删除标签本身)
    db.Model(&post).Association("Tags").Delete(&goTag)
}

// 输出:
// 文章《Go Web开发》的标签: Go Web
示例12:并发安全的数据库操作(工程实践)
// 工程实践:并发场景下的数据库操作
package main

import (
    "fmt"
    "sync"
    "gorm.io/driver/sqlite"
    "gorm.io/gorm"
)

// Counter 计数器模型(演示并发更新)
type Counter struct {
    gorm.Model
    Name  string `gorm:"uniqueIndex"`
    Value int
}

func main() {
    db, _ := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})
    db.AutoMigrate(&Counter{})

    // 初始化计数器
    db.Create(&Counter{Name: "visits", Value: 0})

    // 并发更新(使用乐观锁避免数据竞争)
    var wg sync.WaitGroup
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            // 使用原子更新,避免读-改-写的竞态条件
            db.Model(&Counter{}).
                Where("name = ?", "visits").
                UpdateColumn("value", gorm.Expr("value + ?", 1))
        }()
    }
    wg.Wait()

    var counter Counter
    db.Where("name = ?", "visits").First(&counter)
    fmt.Printf("最终计数: %d(期望: 10)\n", counter.Value)
}

// 输出:
// 最终计数: 10(期望: 10)

💡 工程实践:使用gorm.Expr("value + ?", 1)做原子更新,避免并发读-改-写导致的数据丢失。这是Go工程实践的体现:显式处理并发安全。

示例13:Scopes复用查询条件
package main

import (
    "fmt"
    "time"
    "gorm.io/driver/sqlite"
    "gorm.io/gorm"
)

// 定义可复用的查询Scope(类似Spring Data的Specification)
func ActiveUsers(db *gorm.DB) *gorm.DB {
    return db.Where("deleted_at IS NULL AND age >= ?", 18)
}

func RecentlyCreated(days int) func(db *gorm.DB) *gorm.DB {
    return func(db *gorm.DB) *gorm.DB {
        return db.Where("created_at > ?", time.Now().AddDate(0, 0, -days))
    }
}

func Paginate(page, size int) func(db *gorm.DB) *gorm.DB {
    return func(db *gorm.DB) *gorm.DB {
        return db.Offset((page - 1) * size).Limit(size)
    }
}

func main() {
    db, _ := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})

    // 组合使用Scope(类似Specification的and组合)
    var users []User
    db.Scopes(
        ActiveUsers,
        RecentlyCreated(30),
        Paginate(1, 10),
    ).Find(&users)

    fmt.Printf("最近30天注册的活跃用户: %d\n", len(users))
}
示例14:表驱动测试(工程实践核心)
// internal/repository/user_repository_test.go
package repository_test

import (
    "testing"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
    "gorm.io/driver/sqlite"
    "gorm.io/gorm"
    "myapp/internal/model"
    "myapp/internal/repository"
)

// setupTestDB 创建测试数据库(每个测试用独立的内存数据库)
func setupTestDB(t *testing.T) *gorm.DB {
    db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
    require.NoError(t, err)
    db.AutoMigrate(&model.User{})
    return db
}

// TestUserRepository_Create 表驱动测试用户创建
func TestUserRepository_Create(t *testing.T) {
    tests := []struct {
        name    string
        user    model.User
        wantErr bool
    }{
        {
            name:    "正常创建用户",
            user:    model.User{Username: "john", Email: "john@example.com"},
            wantErr: false,
        },
        {
            name:    "用户名为空应失败",
            user:    model.User{Email: "test@example.com"},
            wantErr: true,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            db := setupTestDB(t)
            repo := repository.NewUserRepository(db)

            err := repo.Create(&tt.user)
            if tt.wantErr {
                assert.Error(t, err)
            } else {
                assert.NoError(t, err)
                assert.NotZero(t, tt.user.ID) // 验证ID被回填
            }
        })
    }
}

💡 工程实践:使用:memory:内存数据库做单元测试,每个测试用例独立数据库,互不干扰。这是Go测试文化的体现。

4.4 对比示例

示例15:N+1问题对比

Java JPA N+1问题

// ❌ N+1问题:查询N个用户,每个用户再查一次posts
List<User> users = userRepository.findAll();
for (User user : users) {
    System.out.println(user.getPosts().size()); // 触发N次额外查询
}

// ✅ 解决:使用JOIN FETCH
@Query("SELECT DISTINCT u FROM User u LEFT JOIN FETCH u.posts")
List<User> findAllWithPosts();

Go GORM N+1问题

package main

import (
    "fmt"
    "gorm.io/driver/sqlite"
    "gorm.io/gorm"
)

func main() {
    db, _ := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})

    // ❌ N+1问题
    var users []User
    db.Find(&users)
    for _, user := range users {
        var posts []Post
        db.Where("user_id = ?", user.ID).Find(&posts) // N次额外查询
        fmt.Printf("%s: %d篇文章\n", user.Username, len(posts))
    }

    // ✅ 解决方案1:Preload(推荐,自动处理)
    var usersWithPosts []User
    db.Preload("Posts").Find(&usersWithPosts)
    // GORM自动执行:SELECT * FROM posts WHERE user_id IN (1,2,3,...)

    // ✅ 解决方案2:Joins(JOIN查询)
    db.Joins("JOIN posts ON posts.user_id = users.id").Find(&users)

    fmt.Printf("使用Preload查询 %d 个用户\n", len(usersWithPosts))
}
示例16:错误处理对比(工程实践)

Java JPA

try {
    User user = userRepository.findById(1L)
        .orElseThrow(() -> new EntityNotFoundException("用户不存在"));
} catch (EntityNotFoundException e) {
    // 处理异常
}

Go GORM错误处理

package main

import (
    "errors"
    "fmt"
    "gorm.io/driver/sqlite"
    "gorm.io/gorm"
)

// 自定义业务错误(工程实践:明确的错误类型)
var (
    ErrUserNotFound = errors.New("用户不存在")
    ErrDuplicate    = errors.New("用户名已存在")
)

func findUser(db *gorm.DB, id uint) (*User, error) {
    var user User
    err := db.First(&user, id).Error
    if err != nil {
        if errors.Is(err, gorm.ErrRecordNotFound) {
            return nil, ErrUserNotFound // 转换为业务错误
        }
        return nil, fmt.Errorf("查询用户失败: %w", err) // 包装错误,保留上下文
    }
    return &user, nil
}

func main() {
    db, _ := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})

    user, err := findUser(db, 999)
    if err != nil {
        if errors.Is(err, ErrUserNotFound) {
            fmt.Println("用户不存在,返回404")
        } else {
            fmt.Println("系统错误:", err)
        }
        return
    }
    fmt.Printf("找到用户: %s\n", user.Username)
}

// 输出:
// 用户不存在,返回404

💡 工程实践:Go的错误处理是显式的,使用errors.Is()检查错误类型,使用fmt.Errorf("%w", err)包装错误保留调用链。这比Java的异常机制更清晰可控。

示例17:软删除与数据恢复
package main

import (
    "fmt"
    "gorm.io/driver/sqlite"
    "gorm.io/gorm"
)

func main() {
    db, _ := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})

    // 软删除(gorm.Model自动支持)
    var user User
    db.First(&user, 1)
    db.Delete(&user) // 设置deleted_at,不真正删除

    // 普通查询自动过滤软删除记录
    var count int64
    db.Model(&User{}).Count(&count)
    fmt.Printf("活跃用户数: %d\n", count)

    // 查询包含软删除的记录
    db.Unscoped().Model(&User{}).Count(&count)
    fmt.Printf("全部用户数(含已删除): %d\n", count)

    // 恢复软删除的记录
    db.Unscoped().Model(&User{}).Where("id = ?", 1).
        Update("deleted_at", nil)

    // 验证恢复
    db.Model(&User{}).Count(&count)
    fmt.Printf("恢复后活跃用户数: %d\n", count)
}

// 输出:
// 活跃用户数: 3
// 全部用户数(含已删除): 4
// 恢复后活跃用户数: 4
示例18:批量操作优化
package main

import (
    "fmt"
    "time"
    "gorm.io/driver/sqlite"
    "gorm.io/gorm"
)

func main() {
    db, _ := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})

    // 批量插入(比循环Create快10倍以上)
    users := make([]User, 1000)
    for i := range users {
        users[i] = User{
            Username: fmt.Sprintf("user_%d", i),
            Email:    fmt.Sprintf("user_%d@example.com", i),
        }
    }

    start := time.Now()
    db.CreateInBatches(users, 100) // 每批100条
    fmt.Printf("批量插入1000条耗时: %v\n", time.Since(start))

    // 批量更新
    db.Model(&User{}).Where("age < ?", 18).Update("age", 18)

    // 批量删除
    db.Where("username LIKE ?", "test_%").Delete(&User{})
}
示例19:数据库迁移最佳实践
// migrations/migrate.go
package migrations

import (
    "fmt"
    "gorm.io/gorm"
    "myapp/internal/model"
)

// Migrate 执行数据库迁移
func Migrate(db *gorm.DB) error {
    fmt.Println("开始数据库迁移...")

    // AutoMigrate:自动创建/更新表结构(只增不删)
    err := db.AutoMigrate(
        &model.User{},
        &model.Post{},
        &model.Comment{},
        &model.Tag{},
    )
    if err != nil {
        return fmt.Errorf("数据库迁移失败: %w", err)
    }

    // 创建初始数据(幂等操作)
    seedData(db)

    fmt.Println("数据库迁移完成")
    return nil
}

func seedData(db *gorm.DB) {
    // 使用FirstOrCreate确保幂等性(类似Flyway的版本控制)
    var adminUser model.User
    db.FirstOrCreate(&adminUser, model.User{Username: "admin"})
    if adminUser.ID == 0 {
        adminUser.Email = "admin@example.com"
        db.Save(&adminUser)
        fmt.Println("创建管理员账号")
    }
}
示例20:完整的Service层实现
// internal/service/user_service.go
package service

import (
    "errors"
    "fmt"
    "golang.org/x/crypto/bcrypt"
    "gorm.io/gorm"
    "myapp/internal/model"
    "myapp/internal/repository"
)

type UserService struct {
    repo repository.UserRepository
}

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

func (s *UserService) Register(username, email, password string) (*model.User, error) {
    // 检查用户名是否已存在
    _, err := s.repo.FindByUsername(username)
    if err == nil {
        return nil, errors.New("用户名已存在")
    }
    if !errors.Is(err, gorm.ErrRecordNotFound) {
        return nil, fmt.Errorf("查询用户失败: %w", err)
    }

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

    user := &model.User{
        Username: username,
        Email:    email,
        Password: string(hashed),
    }

    if err := s.repo.Create(user); err != nil {
        return nil, fmt.Errorf("创建用户失败: %w", err)
    }

    return user, nil
}

5. 问题与解决方案

5.1 问题1:如何避免N+1查询问题?

问题描述:查询用户列表时,每个用户的关联数据触发额外查询,导致性能问题。

解决方案

// ✅ 使用Preload批量加载关联数据
var users []User
db.Preload("Posts").Preload("Posts.Comments").Find(&users)
// GORM自动优化为:
// SELECT * FROM users
// SELECT * FROM posts WHERE user_id IN (1,2,3,...)
// SELECT * FROM comments WHERE post_id IN (...)

为什么有效:Preload使用IN查询批量加载,而非逐条查询。

5.2 问题2:如何处理并发写入冲突?

问题描述:多个goroutine同时更新同一条记录,导致数据丢失。

解决方案

// ✅ 使用乐观锁(Version字段)
type Product struct {
    gorm.Model
    Name    string
    Stock   int
    Version int `gorm:"default:0"` // 版本号
}

func decreaseStock(db *gorm.DB, productID uint, quantity int) error {
    return db.Transaction(func(tx *gorm.DB) error {
        var product Product
        tx.First(&product, productID)

        if product.Stock < quantity {
            return errors.New("库存不足")
        }

        // 乐观锁:只有version匹配才更新
        result := tx.Model(&product).
            Where("version = ?", product.Version).
            Updates(map[string]interface{}{
                "stock":   product.Stock - quantity,
                "version": product.Version + 1,
            })

        if result.RowsAffected == 0 {
            return errors.New("并发冲突,请重试")
        }
        return nil
    })
}

5.3 问题3:如何在测试中使用数据库?

问题描述:单元测试依赖真实数据库,测试速度慢且相互干扰。

解决方案

// ✅ 使用SQLite内存数据库做单元测试
func setupTestDB(t *testing.T) *gorm.DB {
    db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
        Logger: logger.Default.LogMode(logger.Silent), // 测试时关闭日志
    })
    require.NoError(t, err)
    db.AutoMigrate(&User{}, &Post{})

    // 注册清理函数
    t.Cleanup(func() {
        sqlDB, _ := db.DB()
        sqlDB.Close()
    })
    return db
}

为什么有效:内存数据库每次测试独立,速度快,无需清理。

5.4 问题4:如何处理大量数据查询?

问题描述:一次性查询百万条数据导致内存溢出。

解决方案

// ✅ 使用FindInBatches分批处理
db.Where("status = ?", "pending").FindInBatches(&users, 100, func(tx *gorm.DB, batch int) error {
    for _, user := range users {
        // 处理每条记录
        processUser(user)
    }
    fmt.Printf("处理第 %d 批\n", batch)
    return nil // 返回error停止处理
})

5.5 问题5:如何实现软删除的唯一索引?

问题描述:软删除后,相同用户名无法重新注册(唯一索引冲突)。

解决方案

// ✅ 使用复合唯一索引(包含deleted_at)
type User struct {
    gorm.Model
    Username string `gorm:"uniqueIndex:idx_username_deleted"`
    // GORM自动将deleted_at加入复合索引
}

// 或使用自定义索引
type User struct {
    gorm.Model
    Username  string         `gorm:"size:50"`
    DeletedAt gorm.DeletedAt `gorm:"index"`
}
// 在迁移时手动创建:
// CREATE UNIQUE INDEX idx_username ON users(username) WHERE deleted_at IS NULL;

6. 最佳实践

6.1 实践1:始终使用Repository模式隔离数据库

好的示例

// ✅ Service层通过接口依赖Repository
type UserService struct {
    repo UserRepository // 依赖接口,便于测试时Mock
}

不好的示例

// ❌ Service层直接使用*gorm.DB
type UserService struct {
    db *gorm.DB // 直接依赖GORM,难以测试
}

原因:接口隔离使得单元测试可以使用Mock,不依赖真实数据库。

6.2 实践2:使用context控制查询超时

// ✅ 传递context,支持超时和取消
func (r *userRepo) FindByID(ctx context.Context, id uint) (*User, error) {
    var user User
    err := r.db.WithContext(ctx).First(&user, id).Error
    return &user, err
}

// 调用时设置超时
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
user, err := repo.FindByID(ctx, 1)

6.3 实践3:错误处理要明确区分错误类型

// ✅ 区分"未找到"和"系统错误"
func getUser(db *gorm.DB, id uint) (*User, error) {
    var user User
    if err := db.First(&user, id).Error; err != nil {
        if errors.Is(err, gorm.ErrRecordNotFound) {
            return nil, ErrNotFound // 业务错误
        }
        return nil, fmt.Errorf("db error: %w", err) // 系统错误
    }
    return &user, nil
}

6.4 实践4:使用Select限制查询字段

// ✅ 只查询需要的字段,减少数据传输
db.Select("id", "username", "email").Find(&users)

// ❌ 查询所有字段(包括大字段如content)
db.Find(&users)

6.5 实践5:连接池配置要合理

// ✅ 根据业务负载配置连接池
sqlDB, _ := db.DB()
sqlDB.SetMaxOpenConns(25)              // 最大连接数 = CPU核数 * 2 + 磁盘数
sqlDB.SetMaxIdleConns(10)              // 空闲连接数 = MaxOpenConns / 2
sqlDB.SetConnMaxLifetime(time.Hour)    // 防止数据库主动断开连接
sqlDB.SetConnMaxIdleTime(10 * time.Minute) // 空闲连接超时

7. 常见错误

7.1 错误1:忘记传指针给Create/Save

// ❌ 错误:传值,ID不会被回填
user := User{Username: "john"}
db.Create(user)
fmt.Println(user.ID) // 输出: 0(ID没有被回填)

// ✅ 正确:传指针
user := User{Username: "john"}
db.Create(&user)
fmt.Println(user.ID) // 输出: 1(ID被正确回填)

7.2 错误2:在循环中使用同一个变量

// ❌ 错误:所有记录都指向同一个变量
var users []User
db.Find(&users)
for _, user := range users {
    go func() {
        fmt.Println(user.Username) // 竞态条件!user是循环变量
    }()
}

// ✅ 正确:传递副本
for _, user := range users {
    user := user // 创建副本
    go func() {
        fmt.Println(user.Username)
    }()
}

7.3 错误3:Updates忽略零值

// ❌ 错误:Age=0不会被更新
db.Model(&user).Updates(User{Age: 0, Username: "john"})
// 只更新Username,Age被忽略

// ✅ 正确:使用Map更新零值
db.Model(&user).Updates(map[string]interface{}{
    "age":      0,
    "username": "john",
})

7.4 错误4:事务中使用db而非tx

// ❌ 错误:事务中使用db,操作不在事务内
db.Transaction(func(tx *gorm.DB) error {
    db.Create(&user1) // 使用db,不在事务中!
    tx.Create(&user2) // 使用tx,在事务中
    return nil
})

// ✅ 正确:事务中统一使用tx
db.Transaction(func(tx *gorm.DB) error {
    tx.Create(&user1)
    tx.Create(&user2)
    return nil
})

7.5 错误5:忽略GORM的错误返回

// ❌ 错误:忽略错误,可能操作了空数据
var user User
db.First(&user, 999) // 用户不存在,但没检查错误
fmt.Println(user.Username) // 输出空字符串,误以为成功

// ✅ 正确:始终检查错误
var user User
if err := db.First(&user, 999).Error; err != nil {
    if errors.Is(err, gorm.ErrRecordNotFound) {
        fmt.Println("用户不存在")
        return
    }
    fmt.Println("查询失败:", err)
    return
}
fmt.Println(user.Username)

8. 练习题

8.1 基础练习

练习1:实现商品管理CRUD

题目:使用GORM实现商品管理的完整CRUD操作。

要求

  • 定义Product模型(ID、Name、Price、Stock、CategoryID)
  • 实现创建、查询(按ID/按分类)、更新价格、软删除
  • 使用SQLite内存数据库测试

提示:使用gorm.Model内嵌基础字段,gorm:"not null"约束

预期输出

创建商品: iPhone 15, ID=1
按分类查询: 找到 3 个商品
更新价格成功
软删除后活跃商品数: 2
练习2:实现用户-文章一对多关联

题目:实现用户和文章的一对多关联查询。

要求

  • 定义UserArticle模型,建立HasMany关联
  • 创建用户时同时创建关联文章
  • 使用Preload查询用户及其所有文章
  • 统计每个用户的文章数量

提示:使用db.Preload("Articles").Find(&users)

预期输出

用户 alice 有 3 篇文章
用户 bob 有 1 篇文章
练习3:实现分页查询接口

题目:实现支持分页、排序、关键词搜索的用户列表查询。

要求

  • 支持pagesize参数(默认1、10)
  • 支持keyword模糊搜索用户名
  • 支持sort排序(created_at/username)
  • 返回总数和当前页数据

预期输出

第1页(每页5条): 找到 5 条,共 23 条

8.2 进阶练习

练习4:实现带乐观锁的库存扣减

题目:模拟电商秒杀场景,使用乐观锁保证并发安全。

要求

  • 定义Product模型,包含StockVersion字段
  • 启动10个goroutine并发扣减库存
  • 使用乐观锁确保最终库存正确
  • 统计成功和失败的次数

提示:使用WHERE version = ?条件更新,检查RowsAffected

预期输出

并发扣减10次,成功: 10, 失败: 0
最终库存: 90(初始100,扣减10次)
练习5:实现Repository模式 + 单元测试

题目:为商品管理实现完整的Repository模式并编写表驱动测试。

要求

  • 定义ProductRepository接口
  • 实现基于GORM的productRepositoryImpl
  • 使用SQLite内存数据库编写表驱动单元测试
  • 测试覆盖:创建、查询、更新、删除

提示:使用testify/assert断言库

8.3 挑战练习

练习6:实现完整的博客数据层

题目:实现博客系统的完整数据访问层。

要求

  • 模型:User、Post、Comment、Tag(含多对多)
  • Repository接口:UserRepo、PostRepo、CommentRepo
  • Service层:PostService(含创建文章+关联标签的事务)
  • 单元测试:覆盖所有Repository方法
  • 并发测试:验证事务的并发安全性

9. 思考问题

  1. GORM的AutoMigrate和Flyway各有什么优缺点? 生产环境应该选择哪种方案?

  2. 为什么Go选择显式错误处理而不是异常机制? 这对数据库操作的代码风格有什么影响?

  3. GORM的软删除机制有什么潜在问题? 如何处理软删除记录的唯一索引冲突?

  4. Repository模式在Go中是否必要? 什么情况下可以直接使用*gorm.DB,什么情况下必须用Repository?

  5. 如何设计连接池参数? MaxOpenConns设置过大或过小分别有什么影响?

  6. GORM的Preload和Joins有什么区别? 分别适用于什么场景?


10. 总结

10.1 知识回顾

GORM vs Java ORM核心差异

对比项 Java Hibernate/JPA Go GORM
映射方式 @Entity/@Column注解 struct tag
关联定义 @OneToMany/@ManyToOne HasMany/BelongsTo tag
懒加载 FetchType.LAZY(默认) 显式Preload(默认不加载)
事务 @Transactional声明式 db.Transaction()函数式
软删除 需手动实现 gorm.Model自动支持
迁移 Flyway/Liquibase AutoMigrate
错误处理 异常(Exception) 返回值(error)

Go工程实践要点

  • Repository模式隔离数据库依赖,便于测试
  • 显式错误处理,区分业务错误和系统错误
  • 使用context.Context控制超时
  • 表驱动测试 + 内存数据库做单元测试
  • 并发场景使用乐观锁或原子更新

10.2 知识图谱

GORM工程实践

模型设计

gorm.Model内嵌

struct tag映射

关联关系定义

数据操作

CRUD基础

链式查询

原生SQL

批量操作

工程实践

Repository模式

错误处理

Context超时

连接池配置

测试策略

内存数据库

表驱动测试

并发安全测试

并发安全

乐观锁

原子更新

事务隔离

10.3 下一步学习

下一章:第2天综合练习 - 用户认证RESTful API


11. 参考资料


附录:练习题参考答案

练习1答案:商品管理CRUD

package main

import (
    "fmt"
    "gorm.io/driver/sqlite"
    "gorm.io/gorm"
)

type Product struct {
    gorm.Model
    Name       string  `gorm:"size:200;not null"`
    Price      float64 `gorm:"not null"`
    Stock      int     `gorm:"default:0"`
    CategoryID uint    `gorm:"index"`
}

func main() {
    db, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
    db.AutoMigrate(&Product{})

    // 创建
    p := Product{Name: "iPhone 15", Price: 7999.0, Stock: 100, CategoryID: 1}
    db.Create(&p)
    fmt.Printf("创建商品: %s, ID=%d\n", p.Name, p.ID)

    // 按分类查询
    var products []Product
    db.Where("category_id = ?", 1).Find(&products)
    fmt.Printf("按分类查询: 找到 %d 个商品\n", len(products))

    // 更新价格
    db.Model(&p).Update("price", 6999.0)
    fmt.Println("更新价格成功")

    // 软删除
    db.Delete(&p)
    var count int64
    db.Model(&Product{}).Count(&count)
    fmt.Printf("软删除后活跃商品数: %d\n", count)
}

练习4答案:乐观锁库存扣减

package main

import (
    "errors"
    "fmt"
    "sync"
    "sync/atomic"
    "gorm.io/driver/sqlite"
    "gorm.io/gorm"
)

type Product struct {
    gorm.Model
    Name    string
    Stock   int
    Version int `gorm:"default:0"`
}

func decreaseStock(db *gorm.DB, productID uint) error {
    for retry := 0; retry < 3; retry++ {
        var p Product
        if err := db.First(&p, productID).Error; err != nil {
            return err
        }
        if p.Stock <= 0 {
            return errors.New("库存不足")
        }
        result := db.Model(&p).
            Where("version = ?", p.Version).
            Updates(map[string]interface{}{
                "stock":   p.Stock - 1,
                "version": p.Version + 1,
            })
        if result.RowsAffected > 0 {
            return nil // 成功
        }
        // 版本冲突,重试
    }
    return errors.New("并发冲突,重试失败")
}

func main() {
    db, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
    db.AutoMigrate(&Product{})
    p := Product{Name: "限量商品", Stock: 100}
    db.Create(&p)

    var wg sync.WaitGroup
    var success, fail int64

    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            if err := decreaseStock(db, p.ID); err != nil {
                atomic.AddInt64(&fail, 1)
            } else {
                atomic.AddInt64(&success, 1)
            }
        }()
    }
    wg.Wait()

    var final Product
    db.First(&final, p.ID)
    fmt.Printf("成功: %d, 失败: %d, 最终库存: %d\n", success, fail, final.Stock)
}

// 输出:
// 成功: 10, 失败: 0, 最终库存: 90

7.1 错误1:忘记传指针给GORM方法

// ❌ 错误:传值而非指针,ID不会被回填
user := User{Username: "john"}
db.Create(user) // 编译不报错,但ID不会更新

// ✅ 正确:传指针
user := User{Username: "john"}
db.Create(&user) // user.ID会被自动回填
fmt.Println(user.ID) // 输出: 1

原因:GORM需要通过指针修改结构体字段(如回填ID、CreatedAt等)。

7.2 错误2:Updates忽略零值字段

// ❌ 错误:想把Age更新为0,但被忽略
db.Model(&user).Updates(User{Age: 0}) // Age=0是零值,不会更新

// ✅ 正确方式1:使用Map
db.Model(&user).Updates(map[string]interface{}{"age": 0})

// ✅ 正确方式2:使用Select指定字段
db.Model(&user).Select("age").Updates(User{Age: 0})

原因Updates(struct)使用反射跳过零值,避免意外覆盖。

7.3 错误3:在循环中使用同一个变量

// ❌ 错误:所有goroutine共享同一个user变量
for _, user := range users {
    go func() {
        db.Save(&user) // user是循环变量,可能已经改变
    }()
}

// ✅ 正确:复制变量
for _, user := range users {
    u := user // 创建副本
    go func() {
        db.Save(&u)
    }()
}

原因:Go的循环变量在每次迭代中复用同一内存地址。

7.4 错误4:忘记处理gorm.ErrRecordNotFound

// ❌ 错误:不区分"未找到"和其他错误
var user User
if err := db.First(&user, id).Error; err != nil {
    return nil, err // 把"未找到"当系统错误处理
}

// ✅ 正确:区分错误类型
var user User
if err := db.First(&user, id).Error; err != nil {
    if errors.Is(err, gorm.ErrRecordNotFound) {
        return nil, ErrNotFound // 返回404
    }
    return nil, fmt.Errorf("db error: %w", err) // 返回500
}

7.5 错误5:在事务中使用db而非tx

// ❌ 错误:事务中使用了db,操作不在事务内
db.Transaction(func(tx *gorm.DB) error {
    db.Create(&user)  // ❌ 使用db,不在事务中
    db.Create(&post)  // ❌ 使用db,不在事务中
    return nil
})

// ✅ 正确:事务中必须使用tx
db.Transaction(func(tx *gorm.DB) error {
    tx.Create(&user)  // ✅ 使用tx,在事务中
    tx.Create(&post)  // ✅ 使用tx,在事务中
    return nil
})

7.6 错误6:AutoMigrate在生产环境删除列

// ⚠️ 注意:AutoMigrate只会新增列,不会删除列
// 如果你删除了struct中的字段,数据库列不会被删除
// 这是安全设计,但可能导致数据库有废弃列

// ✅ 生产环境建议:使用golang-migrate做版本化迁移
// go get -u github.com/golang-migrate/migrate/v4

7.7 错误7:Preload嵌套层级过深

// ❌ 错误:过深的预加载导致性能问题
db.Preload("Posts.Comments.User.Posts").Find(&users)

// ✅ 正确:按需加载,避免过深嵌套
db.Preload("Posts").Find(&users)
// 需要时再单独查询Comments

7.8 错误8:忘记关闭数据库连接

// ✅ 正确:程序退出时关闭连接池
db, _ := gorm.Open(mysql.Open(dsn), &gorm.Config{})
sqlDB, _ := db.DB()
defer sqlDB.Close() // 确保程序退出时关闭

// 或在优雅关闭中处理
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT)
<-sigChan
sqlDB.Close()
fmt.Println("数据库连接已关闭")

8. 练习题

基础练习

练习1:创建一个Product模型,包含名称、价格、库存字段,实现基本CRUD操作。

// 提示:
type Product struct {
    gorm.Model
    Name  string  `gorm:"size:100;not null"`
    Price float64 `gorm:"not null"`
    Stock int     `gorm:"default:0"`
}
// 实现:Create/FindByID/UpdateStock/Delete

练习2:实现一个分页查询函数,接受页码和每页大小参数,返回用户列表和总数。

// 函数签名:
func ListUsers(db *gorm.DB, page, size int) ([]User, int64, error)

练习3:实现软删除后的数据恢复功能。

// 函数签名:
func RestoreUser(db *gorm.DB, id uint) error

进阶练习

练习4:实现一个带事务的转账功能,从一个账户扣款,向另一个账户加款,任何步骤失败都回滚。

练习5:使用GORM Hooks实现:创建用户时自动发送欢迎邮件(模拟,打印日志即可)。

练习6:实现一个通用的Repository接口,支持泛型(Go 1.18+)。

// 提示:
type Repository[T any] interface {
    Create(entity *T) error
    FindByID(id uint) (*T, error)
    Update(entity *T) error
    Delete(id uint) error
}

挑战练习

练习7:实现一个完整的博客文章系统,包含:

  • User(用户)、Post(文章)、Tag(标签)模型
  • 多对多关系(文章-标签)
  • 分页查询文章列表(支持按标签过滤)
  • 软删除支持

练习8:实现乐观锁机制,防止并发更新库存时的数据竞争。


9. 思考问题

  1. GORM的AutoMigrate和Flyway各有什么优缺点?什么场景下选择哪个?

  2. 为什么Go的GORM选择struct tag而不是注解(annotation)来定义数据库映射?这与Go的设计哲学有什么关系?

  3. GORM的软删除机制(DeletedAt字段)与业务层面的"逻辑删除"有什么区别?各自适用什么场景?

  4. 在高并发场景下,乐观锁和悲观锁各有什么优缺点?GORM如何实现这两种锁?

  5. Repository模式在Go中的实现与Java Spring Data JPA有什么本质区别?Go的接口隐式实现如何影响这种模式?

  6. GORM的Preload和Joins有什么区别?分别在什么场景下使用?

  7. 为什么Go的错误处理(if err != nil)比Java的try-catch更适合数据库操作?

  8. 如何设计一个既支持单元测试(内存数据库)又支持集成测试(真实数据库)的测试策略?


10. 总结

10.1 知识回顾

本章学习了GORM的核心功能和工程实践:

概念 Java Go GORM
模型定义 @Entity + @Column struct + gorm tag
主键 @Id + @GeneratedValue gorm.Model内嵌
时间戳 @CreationTimestamp gorm.Model自动管理
软删除 手动实现@Where gorm.Model内置
关联 @OneToMany等注解 HasMany/BelongsTo
事务 @Transactional db.Transaction(func)
钩子 @PrePersist BeforeCreate等方法
迁移 Flyway/Liquibase AutoMigrate
测试 H2内存数据库 SQLite内存数据库

10.2 知识图谱

GORM核心

模型定义

CRUD操作

关联关系

高级特性

工程实践

gorm.Model内嵌

struct tag映射

自定义表名

Create/Save

First/Find/Where

Update/Updates

Delete/Unscoped

HasOne/HasMany

BelongsTo

ManyToMany

Preload预加载

事务Transaction

钩子Hooks

Scopes复用

原生SQL

Repository模式

Context超时控制

连接池配置

表驱动测试

10.3 GORM vs Hibernate/MyBatis 最终对比

迁移

Go生态

轻量级ORM

链式API

内置特性

GORM

struct tag

查询构建

软删除/钩子/迁移

Java生态

重量级ORM

半ORM

注解驱动

Hibernate

JPA规范

MyBatis

手写SQL

注解配置

10.4 下一步学习

完成本章后,你已经掌握了:

  • ✅ GORM模型定义和数据库连接
  • ✅ 完整的CRUD操作
  • ✅ 关联关系和预加载
  • ✅ 事务处理和钩子
  • ✅ Repository模式工程实践

下一步06-第2天综合练习.md - 将Gin + GORM结合,构建完整的用户认证RESTful API。


11. 参考资料


附录:练习题参考答案

练习1参考答案

package main

import (
    "errors"
    "fmt"
    "gorm.io/driver/sqlite"
    "gorm.io/gorm"
)

type Product struct {
    gorm.Model
    Name  string  `gorm:"size:100;not null"`
    Price float64 `gorm:"not null"`
    Stock int     `gorm:"default:0"`
}

func main() {
    db, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
    db.AutoMigrate(&Product{})

    // Create
    p := Product{Name: "Go编程书", Price: 99.9, Stock: 100}
    db.Create(&p)
    fmt.Printf("创建商品ID: %d\n", p.ID)

    // FindByID
    var found Product
    db.First(&found, p.ID)
    fmt.Printf("找到商品: %s, 价格: %.1f\n", found.Name, found.Price)

    // UpdateStock
    db.Model(&found).Update("stock", found.Stock-1)

    // Delete(软删除)
    db.Delete(&found)
    fmt.Println("商品已软删除")
}

// 输出:
// 创建商品ID: 1
// 找到商品: Go编程书, 价格: 99.9
// 商品已软删除

练习2参考答案

package main

import (
    "fmt"
    "gorm.io/driver/sqlite"
    "gorm.io/gorm"
)

type User struct {
    gorm.Model
    Username string
    Email    string
}

func ListUsers(db *gorm.DB, page, size int) ([]User, int64, error) {
    var users []User
    var total int64

    // 先查总数
    if err := db.Model(&User{}).Count(&total).Error; err != nil {
        return nil, 0, err
    }

    // 分页查询
    offset := (page - 1) * size
    if err := db.Offset(offset).Limit(size).Find(&users).Error; err != nil {
        return nil, 0, err
    }

    return users, total, nil
}

func main() {
    db, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
    db.AutoMigrate(&User{})

    // 创建测试数据
    for i := 1; i <= 25; i++ {
        db.Create(&User{
            Username: fmt.Sprintf("user%d", i),
            Email:    fmt.Sprintf("user%d@example.com", i),
        })
    }

    // 分页查询
    users, total, err := ListUsers(db, 2, 10) // 第2页,每页10条
    if err != nil {
        fmt.Println("查询失败:", err)
        return
    }
    fmt.Printf("总数: %d, 当前页: %d条\n", total, len(users))
    // 输出:总数: 25, 当前页: 10条
}

练习4参考答案(转账事务)

package main

import (
    "errors"
    "fmt"
    "gorm.io/driver/sqlite"
    "gorm.io/gorm"
)

type Account struct {
    gorm.Model
    Owner   string
    Balance float64
}

func Transfer(db *gorm.DB, fromID, toID uint, amount float64) error {
    if amount <= 0 {
        return errors.New("转账金额必须大于0")
    }

    return db.Transaction(func(tx *gorm.DB) error {
        var from, to Account

        if err := tx.First(&from, fromID).Error; err != nil {
            return fmt.Errorf("查询转出账户失败: %w", err)
        }
        if err := tx.First(&to, toID).Error; err != nil {
            return fmt.Errorf("查询转入账户失败: %w", err)
        }
        if from.Balance < amount {
            return errors.New("余额不足")
        }

        // 扣款
        if err := tx.Model(&from).Update("balance", from.Balance-amount).Error; err != nil {
            return fmt.Errorf("扣款失败: %w", err)
        }
        // 加款
        if err := tx.Model(&to).Update("balance", to.Balance+amount).Error; err != nil {
            return fmt.Errorf("加款失败: %w", err)
        }

        fmt.Printf("转账成功: %s -> %s, 金额: %.2f\n", from.Owner, to.Owner, amount)
        return nil
    })
}

func main() {
    db, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
    db.AutoMigrate(&Account{})

    alice := Account{Owner: "Alice", Balance: 1000}
    bob := Account{Owner: "Bob", Balance: 500}
    db.Create(&alice)
    db.Create(&bob)

    if err := Transfer(db, alice.ID, bob.ID, 200); err != nil {
        fmt.Println("转账失败:", err)
    }

    // 验证余额
    db.First(&alice, alice.ID)
    db.First(&bob, bob.ID)
    fmt.Printf("Alice余额: %.2f, Bob余额: %.2f\n", alice.Balance, bob.Balance)
    // 输出:
    // 转账成功: Alice -> Bob, 金额: 200.00
    // Alice余额: 800.00, Bob余额: 700.00
}

4.5 企业级示例

示例21:JWT认证集成(golang-jwt vs Spring Security)

Java Spring Security JWT

// Spring Security配置
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
    }
}

Go JWT实现

// internal/auth/jwt.go
package auth

import (
    "errors"
    "time"
    "github.com/golang-jwt/jwt/v5"
)

// Claims JWT载荷结构
type Claims struct {
    UserID   uint   `json:"user_id"`
    Username string `json:"username"`
    jwt.RegisteredClaims
}

var jwtSecret = []byte("your-secret-key") // 生产环境从环境变量读取

// GenerateToken 生成JWT Token(类似Spring Security的JwtTokenUtil)
func GenerateToken(userID uint, username string) (string, error) {
    claims := Claims{
        UserID:   userID,
        Username: username,
        RegisteredClaims: jwt.RegisteredClaims{
            ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
            IssuedAt:  jwt.NewNumericDate(time.Now()),
            Issuer:    "myapp",
        },
    }
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    return token.SignedString(jwtSecret)
}
// ParseToken 解析JWT Token
func ParseToken(tokenStr string) (*Claims, error) {
    token, err := jwt.ParseWithClaims(tokenStr, &Claims{},
        func(token *jwt.Token) (interface{}, error) {
            if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
                return nil, errors.New("无效的签名方法")
            }
            return jwtSecret, nil
        })
    if err != nil {
        return nil, err
    }
    claims, ok := token.Claims.(*Claims)
    if !ok || !token.Valid {
        return nil, errors.New("无效的token")
    }
    return claims, nil
}

💡 工程实践:JWT Secret必须从环境变量读取,不能硬编码在代码中。生产环境使用RS256非对称加密更安全。

示例22:Gin JWT中间件

// middleware/auth.go
package middleware

import (
    "net/http"
    "strings"
    "github.com/gin-gonic/gin"
    "myapp/internal/auth"
)

// JWTAuth JWT认证中间件(类似Spring Security的JwtAuthenticationFilter)
func JWTAuth() gin.HandlerFunc {
    return func(c *gin.Context) {
        authHeader := c.GetHeader("Authorization")
        if authHeader == "" {
            c.JSON(http.StatusUnauthorized, gin.H{"error": "缺少Authorization头"})
            c.Abort()
            return
        }
        // Bearer token格式
        parts := strings.SplitN(authHeader, " ", 2)
        if len(parts) != 2 || parts[0] != "Bearer" {
            c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization格式错误"})
            c.Abort()
            return
        }
        claims, err := auth.ParseToken(parts[1])
        if err != nil {
            c.JSON(http.StatusUnauthorized, gin.H{"error": "无效的token"})
            c.Abort()
            return
        }
        // 将用户信息存入context(类似Spring Security的SecurityContextHolder)
        c.Set("userID", claims.UserID)
        c.Set("username", claims.Username)
        c.Next()
    }
}

示例23:完整用户注册登录API

// internal/handler/user_handler.go
package handler

import (
    "net/http"
    "github.com/gin-gonic/gin"
    "golang.org/x/crypto/bcrypt"
    "myapp/internal/auth"
    "myapp/internal/model"
    "myapp/internal/repository"
)

type UserHandler struct {
    repo repository.UserRepository
}

func NewUserHandler(repo repository.UserRepository) *UserHandler {
    return &UserHandler{repo: repo}
}

// RegisterRequest 注册请求体
type RegisterRequest struct {
    Username string `json:"username" binding:"required,min=3,max=50"`
    Email    string `json:"email"    binding:"required,email"`
    Password string `json:"password" binding:"required,min=6"`
}

// Register 用户注册(类似Spring MVC的@PostMapping("/register"))
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
    }
    // 加密密码
    hashed, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "密码加密失败"})
        return
    }
    user := &model.User{Username: req.Username, Email: req.Email, Password: string(hashed)}
    if err := h.repo.Create(user); err != nil {
        c.JSON(http.StatusConflict, gin.H{"error": "用户名或邮箱已存在"})
        return
    }
    c.JSON(http.StatusCreated, gin.H{"id": user.ID, "username": user.Username})
}
// LoginRequest 登录请求体
type LoginRequest struct {
    Username string `json:"username" binding:"required"`
    Password string `json:"password" binding:"required"`
}

// Login 用户登录
func (h *UserHandler) Login(c *gin.Context) {
    var req LoginRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }
    user, err := h.repo.FindByUsername(req.Username)
    if err != nil {
        c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
        return
    }
    if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
        c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
        return
    }
    token, err := auth.GenerateToken(user.ID, user.Username)
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "生成token失败"})
        return
    }
    c.JSON(http.StatusOK, gin.H{"token": token, "username": user.Username})
}

// GetProfile 获取当前用户信息(需要JWT认证)
func (h *UserHandler) GetProfile(c *gin.Context) {
    userID := c.GetUint("userID") // 从JWT中间件注入
    user, err := h.repo.FindByID(userID)
    if err != nil {
        c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
        return
    }
    c.JSON(http.StatusOK, gin.H{
        "id":         user.ID,
        "username":   user.Username,
        "email":      user.Email,
        "created_at": user.CreatedAt,
    })
}

示例24:路由注册与依赖组装

// main.go
package main

import (
    "log"
    "github.com/gin-gonic/gin"
    "myapp/config"
    "myapp/internal/handler"
    "myapp/internal/middleware"
    "myapp/internal/model"
    "myapp/internal/repository"
)

func main() {
    // 初始化数据库
    db := config.InitDB()

    // 数据库迁移
    if err := db.AutoMigrate(&model.User{}); err != nil {
        log.Fatal("迁移失败:", err)
    }

    // 依赖注入(手动组装,类似Spring的@Bean配置)
    userRepo := repository.NewUserRepository(db)
    userHandler := handler.NewUserHandler(userRepo)

    // 路由配置
    r := gin.Default()

    // 公开路由(无需认证)
    public := r.Group("/api/v1")
    {
        public.POST("/register", userHandler.Register)
        public.POST("/login", userHandler.Login)
    }

    // 受保护路由(需要JWT认证)
    protected := r.Group("/api/v1")
    protected.Use(middleware.JWTAuth())
    {
        protected.GET("/profile", userHandler.GetProfile)
    }

    log.Println("服务启动在 :8080")
    r.Run(":8080")
}

🔥 企业级:生产环境应使用Wire或fx做依赖注入,避免手动组装的复杂性。

示例25:结构化日志(zap vs Spring Boot Logback)

Java Spring Boot Logback

// application.properties
logging.level.root=INFO
logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n

// 代码中使用
private static final Logger log = LoggerFactory.getLogger(UserService.class);
log.info("用户注册成功: username={}", username);

Go zap结构化日志

// pkg/logger/logger.go
package logger

import (
    "go.uber.org/zap"
    "go.uber.org/zap/zapcore"
)

var Log *zap.Logger

// Init 初始化zap日志(类似Spring Boot的日志配置)
func Init(env string) {
    var cfg zap.Config
    if env == "production" {
        cfg = zap.NewProductionConfig() // JSON格式,适合日志收集
    } else {
        cfg = zap.NewDevelopmentConfig() // 人类可读格式
    }
    cfg.EncoderConfig.TimeKey = "timestamp"
    cfg.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
    Log, _ = cfg.Build()
}

// 使用示例
func ExampleUsage() {
    Init("development")
    defer Log.Sync()

    // 结构化日志(比fmt.Printf更适合生产环境)
    Log.Info("用户注册成功",
        zap.String("username", "john"),
        zap.Uint("user_id", 1),
        zap.String("ip", "192.168.1.1"),
    )

    Log.Error("数据库查询失败",
        zap.String("query", "SELECT * FROM users"),
        zap.Error(fmt.Errorf("connection refused")),
    )
}

// 输出(JSON格式):
// {"level":"info","timestamp":"2026-01-01T10:00:00.000+0800","msg":"用户注册成功","username":"john","user_id":1,"ip":"192.168.1.1"}

5.6 问题6:如何实现数据库读写分离?

问题描述:高并发场景下,读操作远多于写操作,需要读写分离提升性能。

解决方案

// 使用GORM的DBResolver插件实现读写分离
import "gorm.io/plugin/dbresolver"

db.Use(dbresolver.Register(dbresolver.Config{
    // 写库(主库)
    Sources: []gorm.Dialector{mysql.Open(masterDSN)},
    // 读库(从库,可配置多个)
    Replicas: []gorm.Dialector{
        mysql.Open(slave1DSN),
        mysql.Open(slave2DSN),
    },
    Policy: dbresolver.RandomPolicy{}, // 随机选择从库
}).SetMaxOpenConns(10).SetMaxIdleConns(5))

// 使用:GORM自动路由,写操作走主库,读操作走从库
db.Create(&user)          // 自动走主库
db.Find(&users)           // 自动走从库
db.Clauses(dbresolver.Write).Find(&users) // 强制走主库

5.7 问题7:如何监控慢查询?

问题描述:生产环境需要发现并优化慢查询。

解决方案

// 配置慢查询日志
newLogger := logger.New(
    log.New(os.Stdout, "\r\n", log.LstdFlags),
    logger.Config{
        SlowThreshold:             200 * time.Millisecond, // 超过200ms记录慢查询
        LogLevel:                  logger.Warn,
        IgnoreRecordNotFoundError: true,
        Colorful:                  false,
    },
)

// 自定义Logger集成Prometheus指标
type MetricsLogger struct {
    slowQueryCounter prometheus.Counter
}

func (l *MetricsLogger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
    elapsed := time.Since(begin)
    if elapsed > 200*time.Millisecond {
        l.slowQueryCounter.Inc() // 慢查询计数
        sql, rows := fc()
        log.Printf("慢查询 [%.3fms] [rows:%d] %s", float64(elapsed.Nanoseconds())/1e6, rows, sql)
    }
}

5.8 问题8:如何实现数据库连接的优雅关闭?

问题描述:程序收到SIGTERM信号时,需要等待正在执行的数据库操作完成后再关闭。

解决方案

package main

import (
    "context"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"
    "github.com/gin-gonic/gin"
    "gorm.io/gorm"
)

func gracefulShutdown(srv *http.Server, db *gorm.DB) {
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit // 阻塞等待信号

    log.Println("收到关闭信号,开始优雅关闭...")

    // 给正在处理的请求30秒完成时间
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    // 关闭HTTP服务器(停止接受新请求)
    if err := srv.Shutdown(ctx); err != nil {
        log.Fatal("HTTP服务器强制关闭:", err)
    }

    // 关闭数据库连接池
    sqlDB, _ := db.DB()
    if err := sqlDB.Close(); err != nil {
        log.Fatal("数据库连接关闭失败:", err)
    }

    log.Println("服务已优雅关闭")
}

func main() {
    db := initDB()
    r := gin.Default()
    // ... 路由配置

    srv := &http.Server{Addr: ":8080", Handler: r}

    go func() {
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatal("服务启动失败:", err)
        }
    }()

    gracefulShutdown(srv, db) // 阻塞直到收到关闭信号
}

🔥 企业级:优雅关闭是生产环境必备能力,确保数据库事务不会因进程突然终止而损坏。


6.6 实践6:使用事务保证数据一致性

// ✅ 复杂业务操作必须使用事务
func CreatePostWithTags(db *gorm.DB, post *Post, tagNames []string) error {
    return db.Transaction(func(tx *gorm.DB) error {
        // 1. 创建文章
        if err := tx.Create(post).Error; err != nil {
            return err
        }
        // 2. 查找或创建标签
        var tags []Tag
        for _, name := range tagNames {
            var tag Tag
            tx.FirstOrCreate(&tag, Tag{Name: name})
            tags = append(tags, tag)
        }
        // 3. 建立关联
        if err := tx.Model(post).Association("Tags").Replace(tags); err != nil {
            return err
        }
        return nil
    })
}

6.7 实践7:使用索引优化查询性能

// ✅ 为常用查询字段添加索引
type User struct {
    gorm.Model
    Username  string `gorm:"size:50;not null;uniqueIndex"`          // 唯一索引
    Email     string `gorm:"size:100;not null;uniqueIndex"`          // 唯一索引
    Phone     string `gorm:"size:20;index"`                          // 普通索引
    Status    int    `gorm:"default:1;index:idx_status_created"`     // 复合索引
    CreatedAt time.Time `gorm:"index:idx_status_created"`            // 复合索引
}

// 查看生成的索引
// CREATE UNIQUE INDEX `idx_users_username` ON `users`(`username`)
// CREATE UNIQUE INDEX `idx_users_email` ON `users`(`email`)
// CREATE INDEX `idx_users_phone` ON `users`(`phone`)
// CREATE INDEX `idx_status_created` ON `users`(`status`,`created_at`)

6.8 实践8:使用缓存减少数据库压力

// ✅ 结合go-redis实现查询缓存(类似Spring Cache)
import (
    "context"
    "encoding/json"
    "time"
    "github.com/redis/go-redis/v9"
    "gorm.io/gorm"
)

type CachedUserRepo struct {
    db    *gorm.DB
    redis *redis.Client
}

func (r *CachedUserRepo) FindByID(ctx context.Context, id uint) (*User, error) {
    cacheKey := fmt.Sprintf("user:%d", id)

    // 先查缓存
    cached, err := r.redis.Get(ctx, cacheKey).Bytes()
    if err == nil {
        var user User
        json.Unmarshal(cached, &user)
        return &user, nil // 缓存命中
    }

    // 缓存未命中,查数据库
    var user User
    if err := r.db.WithContext(ctx).First(&user, id).Error; err != nil {
        return nil, err
    }

    // 写入缓存(TTL 5分钟)
    data, _ := json.Marshal(user)
    r.redis.Set(ctx, cacheKey, data, 5*time.Minute)

    return &user, nil
}

🔥 企业级:缓存穿透、缓存击穿、缓存雪崩是生产环境三大缓存问题,需要针对性处理。


第2天知识检查清单

完成本章学习后,请检查以下知识点:

  • 理解GORM与Hibernate/MyBatis的设计哲学差异
  • 能够定义GORM模型(gorm.Model、struct tag)
  • 能够配置数据库连接和连接池
  • 掌握CRUD四种基本操作
  • 理解软删除机制(DeletedAt字段)
  • 能够使用链式API构建复杂查询
  • 掌握HasMany/BelongsTo/ManyToMany关联关系
  • 能够使用Preload解决N+1问题
  • 掌握db.Transaction事务处理
  • 理解GORM Hooks(BeforeCreate等)
  • 能够实现Repository模式
  • 能够使用内存数据库编写单元测试
  • 理解JWT认证流程并能实现
  • 掌握结构化日志(zap)的使用
  • 理解优雅关闭的实现方式

企业级实战:完整博客数据层

模型设计

// internal/model/models.go
package model

import (
    "time"
    "gorm.io/gorm"
)

// User 用户模型
type User struct {
    gorm.Model
    Username string  `gorm:"size:50;not null;uniqueIndex" json:"username"`
    Email    string  `gorm:"size:100;not null;uniqueIndex" json:"email"`
    Password string  `gorm:"size:255;not null" json:"-"`
    Avatar   string  `gorm:"size:500" json:"avatar,omitempty"`
    Bio      string  `gorm:"type:text" json:"bio,omitempty"`
    Posts    []Post  `gorm:"foreignKey:AuthorID" json:"posts,omitempty"`
}

// Post 文章模型
type Post struct {
    gorm.Model
    Title     string    `gorm:"size:200;not null;index" json:"title"`
    Slug      string    `gorm:"size:200;not null;uniqueIndex" json:"slug"`
    Content   string    `gorm:"type:longtext;not null" json:"content"`
    Summary   string    `gorm:"type:text" json:"summary"`
    Published bool      `gorm:"default:false;index" json:"published"`
    AuthorID  uint      `gorm:"not null;index" json:"author_id"`
    Author    User      `gorm:"foreignKey:AuthorID" json:"author,omitempty"`
    Tags      []Tag     `gorm:"many2many:post_tags;" json:"tags,omitempty"`
    Comments  []Comment `gorm:"foreignKey:PostID" json:"comments,omitempty"`
    ViewCount int       `gorm:"default:0" json:"view_count"`
}
// Comment 评论模型
type Comment struct {
    gorm.Model
    Content  string   `gorm:"type:text;not null" json:"content"`
    PostID   uint     `gorm:"not null;index" json:"post_id"`
    Post     Post     `gorm:"foreignKey:PostID" json:"-"`
    AuthorID uint     `gorm:"not null;index" json:"author_id"`
    Author   User     `gorm:"foreignKey:AuthorID" json:"author,omitempty"`
    ParentID *uint    `gorm:"index" json:"parent_id,omitempty"` // 支持嵌套评论
    Replies  []Comment `gorm:"foreignKey:ParentID" json:"replies,omitempty"`
}

// Tag 标签模型
type Tag struct {
    gorm.Model
    Name  string `gorm:"size:50;not null;uniqueIndex" json:"name"`
    Slug  string `gorm:"size:50;not null;uniqueIndex" json:"slug"`
    Posts []Post `gorm:"many2many:post_tags;" json:"posts,omitempty"`
}

// PostTag 文章标签中间表(自定义中间表)
type PostTag struct {
    PostID    uint      `gorm:"primaryKey"`
    TagID     uint      `gorm:"primaryKey"`
    CreatedAt time.Time
}

文章Repository实现

// internal/repository/post_repository.go
package repository

import (
    "context"
    "gorm.io/gorm"
    "myapp/internal/model"
)

type PostRepository interface {
    Create(ctx context.Context, post *model.Post) error
    FindByID(ctx context.Context, id uint) (*model.Post, error)
    FindBySlug(ctx context.Context, slug string) (*model.Post, error)
    List(ctx context.Context, q PostQuery) ([]model.Post, int64, error)
    Update(ctx context.Context, post *model.Post) error
    Delete(ctx context.Context, id uint) error
    IncrViewCount(ctx context.Context, id uint) error
}

// PostQuery 文章查询参数
type PostQuery struct {
    AuthorID  uint
    TagSlug   string
    Published *bool
    Keyword   string
    Page      int
    Size      int
    OrderBy   string
}

type postRepositoryImpl struct {
    db *gorm.DB
}

func NewPostRepository(db *gorm.DB) PostRepository {
    return &postRepositoryImpl{db: db}
}

func (r *postRepositoryImpl) Create(ctx context.Context, post *model.Post) error {
    return r.db.WithContext(ctx).Create(post).Error
}

func (r *postRepositoryImpl) FindByID(ctx context.Context, id uint) (*model.Post, error) {
    var post model.Post
    err := r.db.WithContext(ctx).
        Preload("Author").
        Preload("Tags").
        Preload("Comments", func(db *gorm.DB) *gorm.DB {
            return db.Where("parent_id IS NULL").Preload("Author").Preload("Replies.Author")
        }).
        First(&post, id).Error
    if err != nil {
        return nil, err
    }
    return &post, nil
}
func (r *postRepositoryImpl) List(ctx context.Context, q PostQuery) ([]model.Post, int64, error) {
    var posts []model.Post
    var total int64

    query := r.db.WithContext(ctx).Model(&model.Post{})

    // 动态条件构建
    if q.AuthorID > 0 {
        query = query.Where("author_id = ?", q.AuthorID)
    }
    if q.Published != nil {
        query = query.Where("published = ?", *q.Published)
    }
    if q.Keyword != "" {
        query = query.Where("title LIKE ? OR summary LIKE ?",
            "%"+q.Keyword+"%", "%"+q.Keyword+"%")
    }
    if q.TagSlug != "" {
        // 通过标签过滤(JOIN中间表)
        query = query.Joins("JOIN post_tags ON post_tags.post_id = posts.id").
            Joins("JOIN tags ON tags.id = post_tags.tag_id").
            Where("tags.slug = ?", q.TagSlug)
    }

    // 查总数
    query.Count(&total)

    // 排序和分页
    orderBy := "created_at DESC"
    if q.OrderBy != "" {
        orderBy = q.OrderBy
    }
    offset := (q.Page - 1) * q.Size
    err := query.Preload("Author").Preload("Tags").
        Order(orderBy).Limit(q.Size).Offset(offset).
        Find(&posts).Error

    return posts, total, err
}

func (r *postRepositoryImpl) IncrViewCount(ctx context.Context, id uint) error {
    // 原子自增,避免并发问题
    return r.db.WithContext(ctx).Model(&model.Post{}).
        Where("id = ?", id).
        UpdateColumn("view_count", gorm.Expr("view_count + 1")).Error
}

完整测试示例

// internal/repository/post_repository_test.go
package repository_test

import (
    "context"
    "testing"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
    "gorm.io/driver/sqlite"
    "gorm.io/gorm"
    "gorm.io/gorm/logger"
    "myapp/internal/model"
    "myapp/internal/repository"
)

func setupTestDB(t *testing.T) *gorm.DB {
    db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
        Logger: logger.Default.LogMode(logger.Silent),
    })
    require.NoError(t, err)
    err = db.AutoMigrate(
        &model.User{}, &model.Post{},
        &model.Tag{}, &model.Comment{},
    )
    require.NoError(t, err)
    t.Cleanup(func() {
        sqlDB, _ := db.DB()
        sqlDB.Close()
    })
    return db
}

func createTestUser(t *testing.T, db *gorm.DB, username string) *model.User {
    user := &model.User{
        Username: username,
        Email:    username + "@test.com",
        Password: "hashed_password",
    }
    require.NoError(t, db.Create(user).Error)
    return user
}

func TestPostRepository_Create(t *testing.T) {
    db := setupTestDB(t)
    repo := repository.NewPostRepository(db)
    author := createTestUser(t, db, "testauthor")

    post := &model.Post{
        Title:    "测试文章",
        Slug:     "test-post",
        Content:  "文章内容",
        AuthorID: author.ID,
    }
    err := repo.Create(context.Background(), post)
    assert.NoError(t, err)
    assert.NotZero(t, post.ID)
}
func TestPostRepository_List_WithFilters(t *testing.T) {
    db := setupTestDB(t)
    repo := repository.NewPostRepository(db)
    author := createTestUser(t, db, "listauthor")

    // 创建测试数据
    published := true
    unpublished := false
    posts := []*model.Post{
        {Title: "Go入门", Slug: "go-intro", Content: "...", AuthorID: author.ID, Published: published},
        {Title: "Go并发", Slug: "go-concurrency", Content: "...", AuthorID: author.ID, Published: published},
        {Title: "草稿文章", Slug: "draft", Content: "...", AuthorID: author.ID, Published: unpublished},
    }
    for _, p := range posts {
        require.NoError(t, db.Create(p).Error)
    }

    // 测试:只查询已发布文章
    results, total, err := repo.List(context.Background(), repository.PostQuery{
        Published: &published,
        Page:      1,
        Size:      10,
    })
    assert.NoError(t, err)
    assert.Equal(t, int64(2), total)
    assert.Len(t, results, 2)

    // 测试:关键词搜索
    results, total, err = repo.List(context.Background(), repository.PostQuery{
        Keyword: "并发",
        Page:    1,
        Size:    10,
    })
    assert.NoError(t, err)
    assert.Equal(t, int64(1), total)
    assert.Equal(t, "Go并发", results[0].Title)
}

Java习惯的Go反模式

⚠️ Java习惯的Go反模式 — 从Java迁移到Go时常见的错误思维

反模式1:用异常思维处理错误

// ❌ Java思维:期望GORM抛出异常
db.First(&user, 999) // 不检查错误,期望"自动"处理

// ✅ Go思维:显式检查每个错误
if err := db.First(&user, 999).Error; err != nil {
    if errors.Is(err, gorm.ErrRecordNotFound) {
        return nil, ErrNotFound
    }
    return nil, err
}

反模式2:过度使用全局变量

// ❌ Java思维:全局DB实例(类似Spring的单例Bean)
var DB *gorm.DB // 全局变量,难以测试

// ✅ Go思维:通过依赖注入传递
type UserRepo struct {
    db *gorm.DB // 通过构造函数注入
}
func NewUserRepo(db *gorm.DB) *UserRepo { return &UserRepo{db: db} }

反模式3:忽略context传递

// ❌ Java思维:不传context(Java用ThreadLocal)
db.Find(&users)

// ✅ Go思维:始终传递context(支持超时和取消)
db.WithContext(ctx).Find(&users)

反模式4:用继承思维设计模型

// ❌ Java思维:想用继承共享字段
type BaseModel struct { ID uint; CreatedAt time.Time }
type User struct { BaseModel; Username string } // Go不支持继承

// ✅ Go思维:使用组合(内嵌)
type User struct {
    gorm.Model // 内嵌,获得ID/CreatedAt/UpdatedAt/DeletedAt
    Username string
}
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐