Skip to content

Repository 与 ORM

Phase 04 — Database 涵盖:Repository · ORM


1. 学习目标

完成本知识点后,你应该能够:

  • 理解 Repository 模式的数据访问层职责
  • 用接口定义 Repository,实现可测试、可替换的数据访问
  • 了解 GORM 和 sqlc 的基本用法与适用场景
  • database/sql 手写 Repository 与 ORM 之间做出合理选择
  • 为数字孪生项目组织清晰的数据层结构

2. 为什么需要

直接在 HTTP Handler 里写 SQL 会导致:

  • Handler 臃肿,难以测试
  • SQL 散落各处,修改表结构要改很多文件
  • 业务逻辑与数据访问耦合

Repository 模式将数据访问封装到独立层,Handler 只调用 deviceRepo.GetByID(ctx, id),不关心底层是 database/sql、GORM 还是 mock。

当表和 CRUD 操作变多时,纯手写 SQL 重复代码增加。ORM(GORM)或代码生成(sqlc)可以在保持类型安全的前提下减少样板代码——但不应替代对 SQL 和 database/sql 的理解。


3. 核心概念

3.1 Repository 模式

HTTP Handler
     ↓ 调用接口
DeviceRepository (interface)
     ↓ 实现
PostgresDeviceRepository (struct + database/sql)

PostgreSQL

职责划分:

职责
HandlerHTTP 解析、校验、响应
Service / UseCase业务逻辑、事务编排
Repository数据 CRUD,不含业务规则
database/sql连接池、SQL 执行

3.2 Repository 接口设计

go
type Device struct {
    ID         string
    Name       string
    DeviceType string
    Status     string
    CreatedAt  time.Time
}

type DeviceRepository interface {
    Create(ctx context.Context, d *Device) error
    GetByID(ctx context.Context, id string) (*Device, error)
    ListByType(ctx context.Context, deviceType string) ([]Device, error)
    UpdateStatus(ctx context.Context, id, status string) error
    Delete(ctx context.Context, id string) error
}

3.3 ORM 概述

工具类型特点
GORM运行时 ORM功能全,链式 API,学习曲线适中
sqlc编译期代码生成写 SQL → 生成类型安全 Go 代码
database/sql标准库零依赖,完全掌控 SQL

推荐路径:先用 database/sql 理解原理 → 小项目继续手写 Repository → 表多时用 sqlc → 快速原型可用 GORM。


4. 基础语法

4.1 手写 Repository(database/sql)

go
package repository

import (
    "context"
    "database/sql"
    "errors"
    "fmt"
)

type PostgresDeviceRepository struct {
    db *sql.DB
}

func NewPostgresDeviceRepository(db *sql.DB) *PostgresDeviceRepository {
    return &PostgresDeviceRepository{db: db}
}

func (r *PostgresDeviceRepository) Create(ctx context.Context, d *Device) error {
    _, err := r.db.ExecContext(ctx,
        `INSERT INTO devices (id, name, device_type, status)
         VALUES ($1, $2, $3, $4)`,
        d.ID, d.Name, d.DeviceType, d.Status,
    )
    return err
}

func (r *PostgresDeviceRepository) GetByID(ctx context.Context, id string) (*Device, error) {
    var d Device
    err := r.db.QueryRowContext(ctx,
        `SELECT id, name, device_type, status, created_at
         FROM devices WHERE id = $1`, id,
    ).Scan(&d.ID, &d.Name, &d.DeviceType, &d.Status, &d.CreatedAt)

    if errors.Is(err, sql.ErrNoRows) {
        return nil, fmt.Errorf("device %s: %w", id, ErrNotFound)
    }
    if err != nil {
        return nil, err
    }
    return &d, nil
}

var ErrNotFound = errors.New("not found")

4.2 Handler 注入 Repository

go
type DeviceHandler struct {
    repo DeviceRepository
}

func NewDeviceHandler(repo DeviceRepository) *DeviceHandler {
    return &DeviceHandler{repo: repo}
}

func (h *DeviceHandler) GetDevice(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id") // Go 1.22+
    device, err := h.repo.GetByID(r.Context(), id)
    if errors.Is(err, repository.ErrNotFound) {
        http.Error(w, "device not found", http.StatusNotFound)
        return
    }
    if err != nil {
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }
    json.NewEncoder(w).Encode(device)
}

4.3 GORM 基础

bash
go get gorm.io/gorm
go get gorm.io/driver/postgres
go
import (
    "gorm.io/driver/postgres"
    "gorm.io/gorm"
)

type DeviceModel struct {
    ID         string `gorm:"primaryKey;size:32"`
    Name       string `gorm:"size:100;not null"`
    DeviceType string `gorm:"size:50;not null"`
    Status     string `gorm:"size:20;default:idle"`
    CreatedAt  time.Time
}

func main() {
    dsn := "host=localhost user=postgres password=secret dbname=digital_twin port=5432 sslmode=disable"
    db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
    if err != nil {
        log.Fatal(err)
    }

    // 自动迁移(开发环境)
    db.AutoMigrate(&DeviceModel{})

    // 创建
    device := DeviceModel{ID: "AGV-001", Name: "Alpha", DeviceType: "agv", Status: "idle"}
    db.Create(&device)

    // 查询
    var result DeviceModel
    db.First(&result, "id = ?", "AGV-001")

    // 更新
    db.Model(&result).Update("status", "moving")

    // 列表
    var agvs []DeviceModel
    db.Where("device_type = ?", "agv").Find(&agvs)
}

4.4 sqlc 基础

sqlc 工作流:写 SQL → 配置 sqlc.yaml → 生成 Go 代码。

queries/devices.sql

sql
-- name: GetDevice :one
SELECT id, name, device_type, status, created_at
FROM devices WHERE id = $1;

-- name: ListDevicesByType :many
SELECT id, name, device_type, status, created_at
FROM devices WHERE device_type = $1 ORDER BY id;

-- name: CreateDevice :exec
INSERT INTO devices (id, name, device_type, status)
VALUES ($1, $2, $3, $4);

sqlc.yaml

yaml
version: "2"
sql:
  - engine: "postgresql"
    queries: "queries/"
    schema: "schema/"
    gen:
      go:
        package: "db"
        out: "internal/db"

生成后使用:

go
queries := db.New(dbPool)
device, err := queries.GetDevice(ctx, "AGV-001")
devices, err := queries.ListDevicesByType(ctx, "agv")

5. 代码解析

go
type DeviceRepository interface { ... }

接口定义在消费方(或独立的 domain 包),实现放在 repository/postgres。测试时注入 mock 实现,无需真实数据库。

go
func NewDeviceHandler(repo DeviceRepository) *DeviceHandler

依赖注入:在 main.go 中组装依赖链,而非 Handler 内部 sql.Open。便于测试和替换实现。

go
db.AutoMigrate(&DeviceModel{})

GORM 自动建表/加列,适合开发环境。生产环境建议用版本化 migration 工具(如 golang-migrate),而非 AutoMigrate。

go
-- name: GetDevice :one

sqlc 根据 :one:many:exec 注解生成对应返回类型的 Go 函数,SQL 由你完全掌控,类型在编译期检查。


6. JavaScript / TypeScript 对比

概念GoNode.js / TS
ORMGORMPrisma / TypeORM / Sequelize
代码生成sqlcPrisma Client(生成)
Repository手动 interface + impl通常 ORM 直接用在 Service 层
依赖注入手动构造函数注入NestJS DI / 手动
类型安全编译期Prisma / TypeORM 提供 TS 类型

关键差异

  1. Go 社区更倾向显式 Repository 接口,而非 ORM 渗透所有层
  2. sqlc 类似「手写 SQL + 自动生成类型安全代码」,没有运行时反射开销
  3. GORM 的链式 API 类似 Prisma,但错误处理和 NULL 映射需注意

7. 常见错误

错误 1:Repository 包含业务逻辑

go
// ❌ Repository 不应判断「低电量告警」
func (r *Repo) CheckLowBattery(ctx context.Context, id string) error {
    // 查电量 + 发告警 + 更新状态 ...
}

// ✅ 业务逻辑在 Service 层
func (s *DeviceService) CheckLowBattery(ctx context.Context, id string) error {
    t, _ := s.telemetryRepo.GetLatest(ctx, id)
    if t.Battery < 20 {
        return s.alertRepo.Create(ctx, ...)
    }
    return nil
}

错误 2:GORM 生产环境依赖 AutoMigrate

go
// ❌ 生产环境可能意外改表结构
db.AutoMigrate(&DeviceModel{})

// ✅ 使用 golang-migrate 等版本化迁移

错误 3:Repository 返回 database/sql 类型

go
// ❌ 泄漏实现细节
func (r *Repo) Query() *sql.Rows

// ✅ 返回 domain 类型
func (r *Repo) List(ctx context.Context) ([]Device, error)

错误 4:接口过大

go
// ❌ 上帝接口
type DeviceRepository interface {
    Create(...) error
    GetByID(...) (*Device, error)
    // ... 30 个方法
}

// ✅ 按职责拆分
type DeviceReader interface { GetByID(...); List(...) }
type DeviceWriter interface { Create(...); Update(...); Delete(...) }

错误 5:忽略 GORM 的 N+1 查询

go
// ❌ 循环中触发 N 次查询
for _, d := range devices {
    db.Model(&d).Association("Telemetry").Find(&telemetry)
}

// ✅ 使用 Preload
db.Preload("Telemetry").Find(&devices)

8. 实际应用

数字孪生 — 项目数据层结构

internal/
├── domain/
│   ├── device.go          # Device struct
│   └── telemetry.go
├── repository/
│   ├── device.go          # DeviceRepository interface
│   ├── telemetry.go
│   └── postgres/
│       ├── device_repo.go # database/sql 实现
│       └── telemetry_repo.go
├── service/
│   └── device_service.go  # 业务逻辑
└── handler/
    └── device_handler.go  # HTTP 层

典型调用链:

POST /api/v1/telemetry
  → DeviceHandler.CreateTelemetry
  → TelemetryService.Record
  → TelemetryRepository.Insert
  → PostgreSQL

工具选择建议:

项目规模推荐
学习 / 小 demodatabase/sql + 手写 Repository
中型 REST APIsqlc + Repository 接口
快速原型GORM
复杂关联查询多sqlc(SQL 可控)

9. 深入理解

9.1 测试 Repository

go
// 使用 mock(如 testify/mock 或手写)
type MockDeviceRepo struct {
    devices map[string]Device
}

func (m *MockDeviceRepo) GetByID(ctx context.Context, id string) (*Device, error) {
    d, ok := m.devices[id]
    if !ok {
        return nil, ErrNotFound
    }
    return &d, nil
}

Handler 测试注入 MockDeviceRepo,无需 PostgreSQL。

9.2 GORM vs sqlc 对比

维度GORMsqlc
SQL 控制间接(可 Raw SQL)完全掌控
学习成本需学 API需会 SQL
性能有反射开销无反射
复杂查询链式或 Raw直接写 SQL
迁移AutoMigrate需单独工具

9.3 事务跨 Repository

go
func (s *DeviceService) StartMaintenance(ctx context.Context, deviceID, reason string) error {
    return s.db.WithTransaction(ctx, func(tx *sql.Tx) error {
        deviceRepo := postgres.NewDeviceRepositoryTx(tx)
        logRepo := postgres.NewMaintenanceLogRepositoryTx(tx)
        // 两个 repo 共享同一事务
        ...
    })
}

Repository 实现需支持 *sql.Tx 构造,或在 Service 层传入 Querier 接口。


10. 练习

请独立完成,不要查看答案。代码放在 workspace/phase-04/repository/

Level 1 — 基础

练习 1.1:定义 DeviceRepository 接口,包含 CreateGetByIDListByType 三个方法。

练习 1.2:用 database/sql 实现 PostgresDeviceRepository

练习 1.3:在 main.go 中注入 Repository,调用 GetByID 并打印结果。

Level 2 — 应用

练习 2.1:实现 TelemetryRepository 接口及 PostgreSQL 实现。

练习 2.2:编写 DeviceHandler,通过 Repository 提供 GET /api/v1/devices/{id}

练习 2.3:编写 MockDeviceRepository,在测试中验证 Handler 返回 404。

Level 3 — 综合

练习 3.1:用 GORM 重写 Device CRUD,对比与手写 Repository 的代码量。

练习 3.2:配置 sqlc,为 devices 表生成 GetDeviceListDevicesByType

练习 3.3:实现 DeviceService,包含「记录遥测 + 低电量检查 + 插入告警」业务逻辑,调用多个 Repository。

Level 4 — 项目实践

练习 4.1:搭建完整分层项目:domainrepository(interface + postgres impl)→ servicehandler,实现 Device 和 Telemetry 的 REST API,含至少一个 mock 测试。


11. 学习检查

完成练习后,确认你能回答:

  1. Repository 模式的职责边界是什么?什么不该放在 Repository?
  2. 为什么 Handler 应依赖接口而非具体实现?
  3. GORM、sqlc、database/sql 各适合什么场景?
  4. 如何在测试中替换 Repository?
  5. 跨 Repository 的事务应如何组织?
  6. GORM 的 AutoMigrate 为什么不适合生产环境?

12. 下一步

已完成下一知识点关系
Repository, ORMRedis 基础缓存热数据,减轻数据库压力
Cache / Session会话与高频读优化
Pub/Sub实时消息推送

Phase 04 完成后,Project 01 REST API 应已接入 PostgreSQL。Phase 05 将引入 Redis 处理缓存、Session 和实时 Pub/Sub,为数字孪生实时系统做准备。


前置知识提醒

  • 已完成 database/sql 文档与练习,能手写 CRUD
  • 理解 Go interface 和依赖注入(Phase 02)
  • 已完成或并行进行 Project 01 的 PostgreSQL 接入

学习导航

上一篇:PostgreSQL 与 database/sql · 对应练习 · 下一篇:Redis 数据类型