vault backup: 2026-05-06 12:28:28
This commit is contained in:
+4
-1
@@ -223,7 +223,10 @@ db.Unscoped().Delete(&user)
|
||||
|
||||
## 自定义类型作为字段
|
||||
|
||||
当内置类型不够用时,GORM 提供三组接口让 struct 自行控制与数据库的交互方式。
|
||||
当内置类型不够用时,GORM 提供三组接口让 struct 自行控制与数据库的交互方式。详见子文档:
|
||||
|
||||
> [!abstract] 子文档
|
||||
> - [[02-模型定义/自定义类型作为字段]] — ValueScanner 深度用法(JSON、枚举、IP 压缩、加密存储)、GORMDataType 类型覆盖、Resolver API、AutoMigrate 推导流程、常见坑点
|
||||
|
||||
### ValueScanner 模式(推荐)
|
||||
|
||||
|
||||
@@ -0,0 +1,501 @@
|
||||
---
|
||||
tags: [GORM, Go, ORM, 自定义类型, Scanner, Valuer, GORMDataType, JSON, 序列化, Resolver]
|
||||
create time: 2026-05-06 14:30
|
||||
---
|
||||
|
||||
# 自定义类型作为字段
|
||||
|
||||
## 概述
|
||||
|
||||
内置类型(`int`、`string`、`time.Time` 等)覆盖了大部分基础场景,但当你的领域模型包含**值对象**(Value Object)或需要存储特殊格式的数据时——比如将 `map[string]any` 序列化为 JSON、将 IPv4 地址压缩到 4 字节、或用枚举替代魔法数字——就需要实现自定义类型与数据库列之间的双向转换。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
GoVal["Go 结构体值"] -->|driver.Valuer.Value() → 序列化→| DBVal["数据库列值"]
|
||||
DBVal -->|sql.Scanner.Scan() ← 反序列化←| GoVal
|
||||
|
||||
style GoVal fill:#3B82F6,color:#fff
|
||||
style DBVal fill:#EAB308,color:#fff
|
||||
```
|
||||
|
||||
GORM 提供了三组接口来解决不同层次的定制需求:
|
||||
|
||||
| 接口 | 作用方向 | 生效时机 | 适用场景 |
|
||||
|------|----------|----------|---------|
|
||||
| `driver.Valuer` + `sql.Scanner` | 读写双向 | 运行时 INSERT / SELECT | JSON 字段、加密字符串、领域模型转换 |
|
||||
| `GORMDataType()` | 仅建表推导 | AutoMigrate 阶段 | 覆盖列类型定义,不做数据转换 |
|
||||
| `Resolver` (v1.25+) | 读写双向 | 配置期注册 | 第三方包类型无法修改时 |
|
||||
|
||||
> [!tip] 核心原则
|
||||
> 这些接口的本质是**桥接器**——让普通的 Go struct 能够被 `database/sql` 驱动「理解」。GORM 在读取和写入数据时会检测类型是否实现了相应接口,如果实现了就调用它们来桥接 Go 值与 SQL 驱动之间的鸿沟。
|
||||
|
||||
## ValueScanner 模式(最常用)
|
||||
|
||||
同时实现 `driver.Valuer`(Go → DB,写入时的序列化)和 `sql.Scanner`(DB → Go,读取时的反序列化)两个接口,这是最灵活也最常用的方式。
|
||||
|
||||
### JSON 字段——动态结构的万能容器
|
||||
|
||||
MySQL 5.7+ 和 PostgreSQL 都原生支持 JSON 列类型。配合 `Value/Scan` 接口,可以像操作原生 `map[string]any` 一样操作数据库中的 JSON 字段:
|
||||
|
||||
```go
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type JSONMap map[string]any
|
||||
|
||||
// Value: Go → DB(序列化)
|
||||
func (j JSONMap) Value() (driver.Value, error) {
|
||||
if j == nil {
|
||||
return nil, nil // NULL 而非 "null"——这很关键
|
||||
}
|
||||
bytes, err := json.Marshal(j)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return string(bytes), nil // MySQL JSON 列接受字符串
|
||||
}
|
||||
|
||||
// Scan: DB → Go(反序列化)
|
||||
func (j *JSONMap) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*j = nil
|
||||
return nil
|
||||
}
|
||||
// MySQL driver 返回 []byte,某些 wrapper 可能返回 string
|
||||
b, ok := value.([]byte)
|
||||
if !ok {
|
||||
b = []byte(fmt.Sprintf("%v", value))
|
||||
}
|
||||
return json.Unmarshal(b, j)
|
||||
}
|
||||
|
||||
// 使用示例
|
||||
type AppSetting struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Key string `gorm:"size:64;uniqueIndex;not null"`
|
||||
Value JSONMap `gorm:"type:json"`
|
||||
}
|
||||
|
||||
// 写入任意嵌套的 JSON 结构
|
||||
db.Create(&AppSetting{
|
||||
Key: "user_preferences",
|
||||
Value: JSONMap{
|
||||
"theme": "dark",
|
||||
"language": "zh-CN",
|
||||
"notifications": JSONMap{
|
||||
"email": true,
|
||||
"push": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// 直接访问 JSON 内部字段,无需额外解析
|
||||
var setting AppSetting
|
||||
db.Where("key = ?", "user_preferences").First(&setting)
|
||||
fmt.Println(setting.Value["notifications"].(JSONMap)["email"]) // true
|
||||
```
|
||||
|
||||
> [!note] `nil` vs `json.Null`
|
||||
> 当 `JSONMap` 为 `nil` 时,`Value()` 返回 `nil`(即 SQL 的 NULL),而不是 `"null"`(JSON 的空值)。这两者在数据库中是不同的概念——NULL 表示「没有值」,而 `null` 是一个实际的 JSON 值。对于大多数业务场景,返回 nil/NULL 更符合直觉。
|
||||
|
||||
### 自定义枚举——告别魔法数字
|
||||
|
||||
用强类型的枚举替换散落在代码中的魔法数字,让意图一目了然:
|
||||
|
||||
```go
|
||||
type Priority int8
|
||||
|
||||
const (
|
||||
Urgent Priority = 1 // 紧急
|
||||
High Priority = 2 // 高
|
||||
Normal Priority = 3 // 普通
|
||||
Low Priority = 4 // 低
|
||||
)
|
||||
|
||||
func (p Priority) String() string {
|
||||
names := map[Priority]string{
|
||||
Urgent: "urgent",
|
||||
High: "high",
|
||||
Normal: "normal",
|
||||
Low: "low",
|
||||
}
|
||||
s := names[p]
|
||||
if s == "" {
|
||||
return fmt.Sprintf("unknown(%d)", p)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// 写入:Go 值 → DB 整数
|
||||
func (p Priority) Value() (driver.Value, error) {
|
||||
return int8(p), nil
|
||||
}
|
||||
|
||||
// 读取:DB 整数 → Go 值,非法值降级为默认值
|
||||
func (p *Priority) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*p = Normal // 默认优先级
|
||||
return nil
|
||||
}
|
||||
val := int8(value.(int64))
|
||||
// 防御性编码:不信任旧数据中的非法枚举值
|
||||
switch Priority(val) {
|
||||
case Urgent, High, Normal, Low:
|
||||
*p = Priority(val)
|
||||
default:
|
||||
*p = Normal // 未知值回退到 normal
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 使用
|
||||
type Task struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Title string `gorm:"size:128;not null"`
|
||||
Priority Priority `gorm:"not null;default:3"`
|
||||
}
|
||||
|
||||
// 赋值时使用具名常量,语义清晰
|
||||
db.Create(&Task{Title: "Fix payment bug", Priority: Urgent})
|
||||
// 查询结果自动转换为强类型枚举
|
||||
var task Task
|
||||
db.First(&task, 1)
|
||||
fmt.Printf("优先级: %s (%d)\n", task.Priority.String(), task.Priority)
|
||||
// 输出: 优先级: urgent (1)
|
||||
```
|
||||
|
||||
> [!question] 思考题
|
||||
> 为什么 `Value()` 用值接收者 `(p Priority)`,而 `Scan()` 必须用指针接收者 `(p *Priority)`?
|
||||
>
|
||||
> > **答案**:`Value()` 只需要**读取**当前值来转换成数据库格式,不需要修改原对象;而 `Scan()` 的职责是将数据库读出的值**写回**目标变量——如果不使用指针,修改只在方法内部的副本上生效,外部永远看不到变化。这是 Go 指针语义的基本功。
|
||||
|
||||
### IP 地址压缩存储——从 45 字节到 4 字节
|
||||
|
||||
IPv4 地址标准形式 `"192.168.1.1"` 占 15 个字符,IPv6 最长可达 45 个字符。但如果只存原始二进制,IPv4 只需 4 字节,IPv6 只需 16 字节:
|
||||
|
||||
```go
|
||||
package ipaddr
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
"net"
|
||||
)
|
||||
|
||||
// IPAddr 封装 net.IP,提供 Value/Scan 能力
|
||||
type IPAddr net.IP
|
||||
|
||||
func (ip IPAddr) Value() (driver.Value, error) {
|
||||
v := net.IP(ip)
|
||||
// 优先返回压缩后的 IPv4(4 字节),若本身是 IPv6 则返回完整 16 字节
|
||||
if v4 := v.To4(); v4 != nil {
|
||||
return v4, nil
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (ip *IPAddr) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*ip = nil
|
||||
return nil
|
||||
}
|
||||
// MySQL/MariaDB 的 Inet_Aton 返回的是 []byte
|
||||
b, ok := value.([]byte)
|
||||
if !ok {
|
||||
// 兜底:如果是字符串形式的 IP
|
||||
str, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid IP type: %T", value)
|
||||
}
|
||||
parsed := net.ParseIP(str)
|
||||
if parsed == nil {
|
||||
return fmt.Errorf("invalid IP address: %s", str)
|
||||
}
|
||||
*ip = IPAddr(parsed)
|
||||
return nil
|
||||
}
|
||||
parsed := net.IP(b)
|
||||
// 如果是 4 字节的 IPv4,转为标准的 16 字节 IPv4-mapped 格式
|
||||
if len(b) == 4 {
|
||||
if mapped := parsed.To4(); mapped != nil {
|
||||
parsed = mapped
|
||||
}
|
||||
}
|
||||
*ip = IPAddr(parsed)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Host 模型——每个 IP 记录只用 4 字节(IPv4)或 16 字节(IPv6)
|
||||
type Host struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Name string `gorm:"size:128;not null"`
|
||||
IP IPAddr `gorm:"type:varbinary(16)"` // 同时兼容 IPv4 和 IPv6
|
||||
}
|
||||
```
|
||||
|
||||
> [!important] varbinary(16) 为什么是最佳选择?
|
||||
> - `char(45)`:IPv4 浪费了空间,且索引体积大 10 倍以上。
|
||||
> - `char(15)`:只能存 IPv4,IPv6 会截断。
|
||||
> - `varbinary(16)`:IPv4 实际占用 4 字节,IPv6 占用 16 字节——按实际大小存储,兼顾两种协议。
|
||||
>
|
||||
> MySQL 还提供了 `INET_NTOA()` / `INET6_NTOA()` 函数,可在查询时转回人类可读格式:
|
||||
> ```sql
|
||||
> SELECT name, INET6_NTOA(ip) AS readable_ip FROM hosts;
|
||||
> -- 输出: Name | readable_ip
|
||||
> -- Alice | 192.168.1.1
|
||||
> ```
|
||||
|
||||
### 敏感信息加密存储
|
||||
|
||||
在数据库中存储加密后的密码或 API Key,每次读取时自动解密:
|
||||
|
||||
```go
|
||||
import "crypto/aes"
|
||||
|
||||
// EncryptedText 在写入时 AES 加密,读取时自动解密
|
||||
type EncryptedText string
|
||||
|
||||
func (e EncryptedText) Value() (driver.Value, error) {
|
||||
if e == "" {
|
||||
return nil, nil
|
||||
}
|
||||
// 实际项目中应使用安全的密钥管理和加盐策略
|
||||
encrypted, err := encryptString(string(e))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return encrypted, nil
|
||||
}
|
||||
|
||||
func (e *EncryptedText) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*e = ""
|
||||
return nil
|
||||
}
|
||||
b, _ := value.([]byte)
|
||||
decrypted, err := decryptString(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*e = EncryptedText(decrypted)
|
||||
return nil
|
||||
}
|
||||
|
||||
// encryptString / decryptString 由项目统一封装,此处省略实现细节
|
||||
```
|
||||
|
||||
## GORMDataType —— 仅覆盖列类型
|
||||
|
||||
如果你只需要告诉 GORM「这个类型在数据库里应该是什么类型」,但不需要对数据进行额外的序列化/反序列化——数据以 Go 类型的自然形式读写:
|
||||
|
||||
```go
|
||||
type Status byte // 0=pending 1=processing 2=done 3=failed
|
||||
|
||||
func (Status) GORMDataType() string {
|
||||
return "TINYINT UNSIGNED" // 覆盖 GORM 默认的 INT 推导
|
||||
}
|
||||
|
||||
type Ticket struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Status Status `gorm:"not null;default:0"`
|
||||
// 生成的 DDL: status TINYINT UNSIGNED NOT NULL DEFAULT 0
|
||||
}
|
||||
```
|
||||
|
||||
> [!note] GORMDataType 的作用范围
|
||||
> 它只在**建表(AutoMigrate)**时生效,不影响运行时读写行为。也就是说,GORM 会用返回值作为列类型定义 DDL,但数据的序列化和反序列化仍由 `database/sql` 的标准处理逻辑完成。
|
||||
>
|
||||
> 这意味着:如果你的 Go 类型和数据库列类型之间存在天然兼容关系(如 `byte` ↔ `TINYINT UNSIGNED`),仅实现 `GORMDataType()` 就够了,不需要再写 `Value/Scan`。
|
||||
|
||||
### GORMDataType + Value/Scan 的组合用法
|
||||
|
||||
两者可以同时实现——各司其职、互不干扰:
|
||||
|
||||
```go
|
||||
type Priority int8
|
||||
|
||||
// GORMDataType 决定建表的列类型
|
||||
func (Priority) GORMDataType() string {
|
||||
return "TINYINT UNSIGNED"
|
||||
}
|
||||
|
||||
// Value/Scan 决定运行时的数据转换
|
||||
func (p Priority) Value() (driver.Value, error) {
|
||||
return int8(p), nil
|
||||
}
|
||||
|
||||
func (p *Priority) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*p = 3
|
||||
return nil
|
||||
}
|
||||
*p = Priority(value.(int64))
|
||||
return nil
|
||||
}
|
||||
|
||||
// 建表时 → priority TINYINT UNSIGNED NOT NULL DEFAULT 3
|
||||
// 运行时 → int8 ↔ Priority 的自动序列化/反序列化
|
||||
```
|
||||
|
||||
> [!tip] 该用哪个接口?速查表
|
||||
> | 需求 | 最小实现 | 说明 |
|
||||
> |------|----------|------|
|
||||
> | 仅需改列类型,数据自然兼容 | `GORMDataType()` | 最简单,只影响 DDL |
|
||||
> | Go 类型和 DB 类型需转换 | `driver.Valuer` + `sql.Scanner` | 完全控制序列化和反序列化 |
|
||||
> | 第三方类型,不能改源码 | `Resolver` API | 在不修改类型的前提下注册解析逻辑 |
|
||||
> | 三者全要 | 全部实现 | `GORMDataType` 管建表,`Value/Scan` 管读写 |
|
||||
|
||||
## 全局类型解析器(Resolver API)
|
||||
|
||||
GORM v1.25+ 引入了 Resolver 机制,允许**在不修改类型定义**的前提下注册自定义解析逻辑。这在以下场景中特别有用:
|
||||
|
||||
| 场景 | 说明 |
|
||||
|------|------|
|
||||
| 第三方包类型 | 如 `net.IP`、`json.RawMessage`,你无法给它们添加方法 |
|
||||
| 跨模块复用 | 多个模块引用同一个自定义类型,避免在每个模块中重复实现接口 |
|
||||
| 集中管理 | 所有类型映射关系集中在一个初始化点,便于维护和审计 |
|
||||
|
||||
```go
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"gorm.io/gorm/schema"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// 为 net.IP 注册解析器
|
||||
type IPResolver struct{}
|
||||
|
||||
func (IPResolver) Value(src any) (driver.Value, error) {
|
||||
ip := src.(net.IP)
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
return v4, nil
|
||||
}
|
||||
return ip, nil
|
||||
}
|
||||
|
||||
func (IPResolver) Scan(dest any, src any) error {
|
||||
b, _ := src.([]byte)
|
||||
*dest.(*net.IP) = net.IP(b)
|
||||
return nil
|
||||
}
|
||||
|
||||
func initDB() (*gorm.DB, error) {
|
||||
return gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||
Schema: &schema.Schema{
|
||||
Resolver: resolver.FullSaveResolver{
|
||||
Resolvers: map[reflect.Type]resolver.Resolver{
|
||||
reflect.TypeOf(net.IP{}): IPResolver{},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
> [!warning] 关于 Resolver 的现状
|
||||
> GORM 官方对 Resolver API 的稳定性和文档完善度仍在迭代中。**如果你的类型可以自己添加方法,始终优先选择直接实现 `driver.Valuer` + `sql.Scanner`**——代码内聚性更好,IDE 可以做类型检查和补全,而且不依赖版本兼容性。
|
||||
|
||||
## AutoMigrate 时的类型推导流程
|
||||
|
||||
当你调用 `db.AutoMigrate(&User{})` 时,GORM 按照以下决策树确定每个字段的列类型:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["字段类型 T"] --> B{"是否实现<br/>GORMDataType()?"}
|
||||
B -- "是" --> C["使用返回值作为列类型<br/>跳过后续检查"]
|
||||
B -- "否" --> D{"T 是否为 GORM<br/>已知内置类型?"}
|
||||
D -- "是" --> E["使用内置类型映射表<br/>如 string → VARCHAR(n)"]
|
||||
D -- "否" --> F{"是否有 sql.Scanner<br/>实现?"}
|
||||
F -- "有" --> G["尝试推断底层驱动类型"]
|
||||
F -- "无" --> H["使用反射探测<br/>可能产生意外结果"]
|
||||
|
||||
style A fill:#4FC08D,color:#fff
|
||||
style C fill:#3B82F6,color:#fff
|
||||
style E fill:#A0AEC0,color:#fff
|
||||
style H fill:#EF4444,color:#fff
|
||||
```
|
||||
|
||||
> [!warning] 最危险的分支
|
||||
> 流程图最右侧的 H 分支——既没有 `GORMDataType()`,也没有 `sql.Scanner`——GORM 会通过反射去猜测类型。这种猜测不可靠,可能导致列类型不符合预期。**最佳实践:任何自定义类型至少实现 `GORMDataType()`。**
|
||||
|
||||
## 综合实战:配置项存储系统
|
||||
|
||||
实际项目中经常遇到需要存储动态 key-value 配置的场景。下面展示如何结合 JSON 字段、`OnConflict` 和复合索引构建一个健壮的通用配置存储:
|
||||
|
||||
```go
|
||||
type AppConfig struct {
|
||||
gorm.Model
|
||||
Namespace string `gorm:"size:64;not null;index:idx_ns_key"`
|
||||
Key string `gorm:"size:128;not null;index:idx_ns_key"`
|
||||
Data JSONMap `gorm:"type:json;not null"`
|
||||
}
|
||||
|
||||
func (AppConfig) TableName() string {
|
||||
return "app_configs"
|
||||
}
|
||||
|
||||
// Upsert:不存在则插入,存在则更新 Data 字段
|
||||
func SetConfig(db *gorm.DB, ns, key string, data map[string]any) error {
|
||||
config := AppConfig{
|
||||
Namespace: ns,
|
||||
Key: key,
|
||||
Data: JSONMap(data),
|
||||
}
|
||||
return db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "namespace"}, {Name: "key"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"data", "updated_at"}),
|
||||
}).Create(&config).Error
|
||||
}
|
||||
|
||||
// Get:按 namespace + key 精确查找
|
||||
func GetConfig(db *gorm.DB, ns, key string) (JSONMap, error) {
|
||||
var config AppConfig
|
||||
if err := db.Select("data").Where("namespace = ? AND key = ?", ns, key).
|
||||
First(&config).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return config.Data, nil
|
||||
}
|
||||
|
||||
// ListByNamespace:列出某个命名空间下的所有键
|
||||
func ListKeys(db *gorm.DB, ns string) ([]string, error) {
|
||||
var configs []AppConfig
|
||||
if err := db.Select("key").Where("namespace = ?", ns).Find(&configs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys := make([]string, len(configs))
|
||||
for i, c := range configs {
|
||||
keys[i] = c.Key
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
```
|
||||
|
||||
> [!tip] 为什么 JSON 比拆多列更好?
|
||||
> - **灵活性**:字段增减无需 ALTER TABLE,零停机变更。
|
||||
> - **嵌套能力**:可以存储任意深度的结构化数据。
|
||||
> - **引擎级支持**:MySQL 5.7+ 和 PostgreSQL 都有 JSON 查询函数(`JSON_EXTRACT`、`->>`)。
|
||||
> - **性能**:现代 MySQL 对 JSON 列做了内存优化,小对象查询效率接近原生列。
|
||||
>
|
||||
> **代价**:失去了行级的类型安全校验和部分字段的独立索引能力。如果需要按 JSON 内部字段查询,MySQL 5.7+ 支持生成列(Generated Column)+ 索引的组合方案。
|
||||
|
||||
## 常见坑点速查
|
||||
|
||||
| 问题 | 原因 | 解决方案 |
|
||||
|------|------|---------|
|
||||
| `Scan` 收不到 `nil` 值 | 未处理 `value == nil` 分支,导致 panic | 在 `Scan()` 开头检查 `nil`,提前返回 |
|
||||
| `Value()` 序列化失败导致整个 INSERT 中断 | `json.Marshal` 遇到不可序列化的值(如 channel) | 做好错误处理和防御性编码 |
|
||||
| `GORMDataType()` 没生效 | 方法的 receiver 类型声明错误(如用了 `*Priority`) | 确保方法是**非指针类型**上的公开方法 |
|
||||
| `PostgreSQL JSONB` 与 `MySQL JSON` 行为差异 | 某些驱动对 nil 的处理不一致 | 用 `Select()` 显式指定 column 类型或做驱动检测 |
|
||||
| `AutoMigrate` 改变了已有列类型 | 忘记实现 `GORMDataType()`,GORM 按默认映射推断 | 先跑 `Describe table` 确认实际列类型 |
|
||||
| `net.IP` 存成变长字符串 | MySQL 驱动可能对 `[]byte` 的行为不确定 | 明确限制长度或使用 `INET6_NTOA` 辅助查询 |
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[../02-模型定义]] — 父文档,包含字段 tag、主键策略等概述
|
||||
- [[12-自定义字段类型]] — 更广泛的自定义类型生态(包括第三方库集成)
|
||||
- [[04-条件查询]] — JSON 字段的查询技巧(MySQL JSON_EXTRACT、PostgreSQL ->>)
|
||||
Reference in New Issue
Block a user