外观
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职责划分:
| 层 | 职责 |
|---|---|
| Handler | HTTP 解析、校验、响应 |
| 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/postgresgo
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 :onesqlc 根据 :one、:many、:exec 注解生成对应返回类型的 Go 函数,SQL 由你完全掌控,类型在编译期检查。
6. JavaScript / TypeScript 对比
| 概念 | Go | Node.js / TS |
|---|---|---|
| ORM | GORM | Prisma / TypeORM / Sequelize |
| 代码生成 | sqlc | Prisma Client(生成) |
| Repository | 手动 interface + impl | 通常 ORM 直接用在 Service 层 |
| 依赖注入 | 手动构造函数注入 | NestJS DI / 手动 |
| 类型安全 | 编译期 | Prisma / TypeORM 提供 TS 类型 |
关键差异:
- Go 社区更倾向显式 Repository 接口,而非 ORM 渗透所有层
- sqlc 类似「手写 SQL + 自动生成类型安全代码」,没有运行时反射开销
- 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工具选择建议:
| 项目规模 | 推荐 |
|---|---|
| 学习 / 小 demo | database/sql + 手写 Repository |
| 中型 REST API | sqlc + 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 对比
| 维度 | GORM | sqlc |
|---|---|---|
| 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 接口,包含 Create、GetByID、ListByType 三个方法。
练习 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 表生成 GetDevice 和 ListDevicesByType。
练习 3.3:实现 DeviceService,包含「记录遥测 + 低电量检查 + 插入告警」业务逻辑,调用多个 Repository。
Level 4 — 项目实践
练习 4.1:搭建完整分层项目:domain → repository(interface + postgres impl)→ service → handler,实现 Device 和 Telemetry 的 REST API,含至少一个 mock 测试。
11. 学习检查
完成练习后,确认你能回答:
- Repository 模式的职责边界是什么?什么不该放在 Repository?
- 为什么 Handler 应依赖接口而非具体实现?
- GORM、sqlc、database/sql 各适合什么场景?
- 如何在测试中替换 Repository?
- 跨 Repository 的事务应如何组织?
- GORM 的
AutoMigrate为什么不适合生产环境?
12. 下一步
| 已完成 | 下一知识点 | 关系 |
|---|---|---|
| Repository, ORM | Redis 基础 | 缓存热数据,减轻数据库压力 |
| 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 接入