GoLang
基础语法
变量和常量
变量声明和变量类型
-
Go 在不同类型的项之间赋值时需要显式转换
-
可以使用 **
fmt.Printf("v is of type %T\n", v)**这个%T来打印变量v的类型
常量声明的两种方式
不能把一个函数的返回值赋给常量,因为函数的返回值是运行时确定的,我们只可以给常量一个编译期就确定的值
无类型常量(隐式类型声明 ):const Pi = 3.14,此时 Pi ** 没有固定类型,它可以根据上下文自动转化为 float32 或 float64,甚至是自定义的浮点类型**
- 直到它被真正使用(赋值给变量或作为函数参数)时,编译器才会根据上下文环境(Context)来决定它的具体类型
显式类型声明 :const Pi float64 = 3.14159
Go的枚举实现------iota 计数器
Go没有枚举类型,但是有iota 计数器, const 关键字与 iota 标识符的配合就可以实现枚举类型了
iota 是 Go 的一个预定义标识符 。它的本质是一个编译期修改的常量计数器
-
在
const关键字出现的行,iota被重置为 0。随后,在同一个常量声明块中,每新增一行,iota的值就会自动加 1 -
隐式重复 :Go 的常量声明有一个特性------如果一行没有写表达式,它会复用上一行的表达式(这在某些复杂的声明里面可能是一个坑)
Go
const (
Read = 1 << iota // iota = 0, 结果 001 (1)
Write // 自动复用表达式 1 << iota, iota = 1, 结果 010 (2)
Execute // 自动复用表达式 1 << iota, iota = 2, 结果 100 (4)
Admin // 自动复用表达式 1 << iota, iota = 3, 结果 ???
)
控制流语句
if条件控制语句
Go 的 if 语句与 C 或 Java 相比,最显著的区别是不需要括号,但必须有大括号
Go
if condition {
// 执行代码
}else if condition2{
//执行代码
}else{
//就算只有一行,也必须写这个大括号
}
-
**Go 的特色:初始化语句->**可以在
if关键字后面直接声明一个变量,这个变量的作用域仅限于该if-else块Goif num := 10; num > 0 { fmt.Println(num, "是正数") } // 这里无法访问 num现代 CPU 都有分支预测器。如果你的
if条件极度不平衡(例如 99% 的情况都走某一个分支),CPU 会预先加载该路径的指令
switch 语句
在 C/Java 中,如果你忘记在 case 结尾写 break,程序会继续执行下一个 case(即穿透)。但在 Go 中:
-
默认不穿透 :每个
case执行完会自动跳出switch,不再需要手动写break -
显式穿透 :如果你确实需要穿透逻辑,必须显式使用
fallthrough关键字Goswitch level { case 3: fmt.Println("步骤 3:高级系统配置") fallthrough // 强制进入下一个 case case 2: fmt.Println("步骤 2:中级性能优化") fallthrough // 继续穿透 case 1: fmt.Println("步骤 1:基础环境检查") default: fmt.Println("任务完成") }fallthrough必须是case块中的最后一条语句。如果它后面还有代码,编译器会报错
for循环控制
Go 只有 for 这一种循环结构,而且 **for**循环也是没有括号的
-
基础的三段式的for循环,这个for循环和C/Java等语言类似
Gofor i := 0; i < 10; i++ { // 逻辑代码 } -
省略了前置和后置条件就变成了"伪 while" 循环
Gofor condition { // 只要条件成立就一直执行 }
复杂数据类型
切片(Slice)
在 Go 中,数组的长度是类型的一部分且固定不变(因此获取数组长度是O(1)的操作,不需要像C语言一样进行遍历),这在处理动态数据时非常不便
切片看起来像动态数组(常见的使用场景) ,但底层其实是一个结构体------****SliceHeader
在运行期间,每个切片实际上由三个部分组成:
-
因此传递大切片非常高效------因为它只传递了这 16 或 24 字节的结构体,而不是整个数据集
-
可以使用 **
len(s)和cap(s)**函数来获取长度和容量信息
动态扩容:append 的策略
当你往切片里 append 元素导致超过 Cap 时,Go 会自动扩容:分配一块更大的新内存->将旧数据拷贝过去->返回指向新数组的新切片
底层算法(Go 1.18+ 优化版)
-
如果当前容量小于 256,则翻倍(2×2\times2×)
-
如果大于 256,则按照 (prev_cap+3×256)/4(prev\_cap + 3 \times 256) / 4(prev_cap+3×256)/4 的公式平滑增加,直到满足需求。
func append(slice []Type, elems ...Type) []Type这是append函数的API
-
slice:这是你要操作的源切片 -
elems:这是你要追加的一个或多个元素,其类型必须与切片的元素类型一致 -
返回值:它会返回一个新的切片
Go
func bad(s []int) { s = append(s, 99) } // 改的是局部副本的 header,白改
func good(s []int) []int { return append(s, 99) } // 必须 return
func ptr(s *[]int) { *s = append(*s, 99) } // 或者传指针
上面的代码块就很好的演示了slice的本质是三个元素构成的结构体,第一个bad函数因为传入的是s而不是引用,因此是值传递,因此在函数体里面修改s的操作仅仅会影响这个s的副本;第二个因为good使用return出去了因此是可以保存改变的,但是需要外面有变量进行承接;第三个比较特殊主要的原因是因为传入的不是值而是原来的引用,因此函数体里面的改动会直接影响到外面的
-
直接声明(零值切片):最简单的方式是只声明类型而不初始化 **
var s []int****,这里的切片s其实就是一个nil切片,nil切片是可以直接进行append操作的(对应的len=0,cap=0)**- 所有 len=0 的非 nil 切片,指针都指向同一个全局变量 runtime.zerobase,不占额外内存。
-
字面量初始化:在声明的同时赋予初始值 **
s := []int{10, 20, 30}** -
使用
make函数初始化:****s := make([]int, 5, 10) // 长度为5,容量为10-
它可以预分配内存,避免频繁扩容带来的性能损耗
-
a := make([]int, 5) // len(a)=5,这个不会指定容量,但是默认容量和长度相等了
-
因为切片其实本质上和指针是类似的,因此其就像是数组的一个视图,一个数组可以有多个切片,而且他们都对数组有修改的权限
-
s1 := a[1:3](从索引 1 截取到 3,不包含 3),切片都是左闭右开的 -
切片是会影响原来的数组的,这就是为什么叫作视图的意义(但是主要的原因还是ptr)
Goa := []int{1, 2, 3, 4, 5} s := a[0:2] // s=[1,2], len=2, cap=5(有余量!) s = append(s, 100) // len<cap → 原地写到下标2 fmt.Println(a) // 输出: [1 2 100 4 5] ← a[2] 被悄悄改了! fmt.Println(s) // 输出: [1 2 100]这里的原因是因为s:=a0:2表示的是ptr=a,len=2,cap=5 ,然后因为len<cap因此不会去开辟一块新的地方,但如果len=cap的话对应ptr就装不下这么多数据因此这个ptr就不再指向a了,因此就不会改变原来的数组a
-
三索引切片
a[0:2:2]是a[low:high:max]而不是Python模式下的a[start:stop:step],这样主要的作用是把 cap 也焊死成 2,而不是根据a的具体长度去进行变化的cap=a.length-start的默认模式Gos := a[0:2:2] // low=0, high=2, max=2 // len = high - low = 2 // cap = max - low = 2 ← 第三个数是用来限制 cap 的! s := a[0:2] // 这种写法如果a的长度就是2的话那么cap=2,但是如果a的长度是5的话那么cap=5
哈希表(Map)
一种存储 键值对(Key-Value pairs) 的无序集合
几种常见的声明方式:
-
字面量初始化 :直接赋予键值。
m := map[string]int{"Alice": 25, "Bob": 30}- 这表示key的类型是string,然后值的类型是int
-
使用
make函数 :预分配空间以提高性能。m := make(map[string]int, 10) // 预估存储 10 个元素 -
零值 Map :
var m map[string]int注意 :此时m是nil。你可以从nilMap 中读取数据,但如果尝试写入,程序会直接 Panic(崩溃)-
nil map:读没事,写 panic
Govar m map[string]int // 只声明,没初始化 → nil map fmt.Println(m["x"]) // 输出: 0 ← 读 nil map 合法,返回零值 fmt.Println(m == nil) // true m["x"] = 1 // 💥 panic: assignment to entry in nil map-
这里因为返回0值是默认操作,因为Go的Map不是像Java一样的对象,因此没有办法直接返回nil,因此就需要用Ok那个双返回值来进行处理了。写panic的原因是因为还没有初始化分配地址呢
-
Go 的 nil map 写 panic 是一个道理------只是 Go 读 nil map 不报错(返回零值),这点比 Java 宽松。
-
-
Map 实际上是一个 哈希表(Hash Table) ,它的底层结构体是 hmap
-
hmap : 记录了整个 Map 的元信息,比如当前的元素数量
count、桶的数量(以 $2^B$ 表示)以及指向桶数组的指针
-
bmap (Bucket) : 这是存储数据的基本单元。每个桶里固定存放 8 个键值对。
- 为了优化内存对齐,桶的内部构造是:先放 8 个 Key,再放 8 个 Value,最后是溢出桶的指针
-
由于读取不存在的 Key 会返回零值,我们无法仅通过
v来判断一个 Key 是否真的存在(比如这个value类型里面存的本身就是0呐),因此我们可以使用双返回值来进行判断Govalue, ok := m["key"] if ok {// Key 存在 } else {// Key 不存在 }-
Java 的 null 方案其实是有缺陷的
JavaMap<String,Integer> m = new HashMap<>(); m.put("a", null); // Java 允许 value 存 null m.get("a"); // 返回 null ------ 到底是"没这个key"还是"值就是null"?分不清!- 就是因为这里返回null到底是不是值的原因导致有二义性,因此后来补充了API------ containsKey() 和 getOrDefault(),而Go一开始就考虑好了的
-
-
Java有显式并发容器(ConcurrentHashMap),而且Java 没有「跨线程实时检测数据竞争」的廉价机制。它只有 modCount 做迭代器的 fail-fast(单线程遍历时被改会抛 ConcurrentModificationException),但那是给迭代用的,管不了两个线程同时 put,因此Go就不需要处理这些Go的不用创建一个并发的HashMap
-
并发写Map直接Panic崩溃
Gom := map[int]int{} go func() { for { m[1] = 1 } }() // 一个 goroutine 写 go func() { for { _ = m[1] } }() // 另一个读 // 💥 fatal error: concurrent map read and map write-
Go runtime 在 map 结构里埋了个写标志位。一个 goroutine 写时打标,别的 goroutine 进来发现标志位被占,直接 fatal error 把整个进程干掉。
-
fatal error,不是 panic。连recover() 都救不回来,比普通 panic 更狠
-
而Java并发读写HashMap是会数据错乱的,但是不一定崩,但是是Go 故意设计为崩溃------因为并发读写 map 是 bug,runtime 宁可让你当场崩,也不让你带着脏数据裸奔。
-
-
并发读写的方案:
-
sync.RWMutex + 普通 map(推荐方案)
Gotype SafeMap struct { mu sync.RWMutex m map[string]int } func (s *SafeMap) Get(k string) (int, bool) { s.mu.RLock() // 读锁,多个读可并发 defer s.mu.RUnlock() v, ok := s.m[k] return v, ok } func (s *SafeMap) Set(k string, v int) { s.mu.Lock() // 写锁,独占 defer s.mu.Unlock() s.m[k] = v } -
sync.Map(适用于读多几乎不写的场景下,或者各个goroutine不会操作同一个锁的场景)不像 ConcurrentHashMap那样全能
-
-
并发扩容机制:Java ConcurrentHashMap 支持「多线程协同扩容」,Go map 是「单线程渐进式扩容 + 完全不支持并发」
-
Go语言:分配 newbuckets,保留 oldbuckets 指针,但不一次性搬完。之后每次写操作(assign/delete)顺手迁移 1~2 个旧桶到新桶。读的时候新旧桶都查。
-
Java语言:transfer() 支持多线程帮忙搬------**每个线程通过 transferIndex 认领一段桶区间并行迁移,迁移完的桶放 ForwardingNode 标记,其他线程读到这个标记会转到新表。**这是真正的并发扩容
-
-
哈希表遍历的特征------Go的for...range遍历Mao是遍历随机的
-
Java HashMap:遍历顺序由桶下标 + 桶内插入顺序决定。它看起来杂乱,但对同一个没被修改的 map 实例,多次遍历顺序是稳定、可复现的。它只是「无规律」,不是「每次都变」。
-
Go map:runtime 在 range 启动时故意塞一个随机起始桶 + 随机起始槽位,所以同一个 map 连续遍历两次,顺序都可能不同。这是 Go 刻意设计的------就是要防止你写出依赖遍历顺序的代码。
-
Java = 顺序无规律但同实例稳定;Go = 每次遍历主动随机化。
-
函数
一个标准的 Go 函数声明包含函数名、参数列表、返回值类型以及函数体:
Go
func functionName(param1 type1, param2 type2) returnType {
// 函数体
return value
}
-
Go 的函数有几个非常实用的特性:
-
多返回值:一个函数可以返回多个结果->最常见的就是错误处理里面
-
变长参数 :使用
...type语法可以接受任意数量的同类型参数 -
命名返回值 :你可以在函数头部定义返回变量的名字,这样在函数体内直接修改这些变量,最后只需调用
return即可Gofunc swap(x, y int) (first, second int) { first = y second = x return // 这里的 return 不需要再显式写变量名,被称为"裸返回(Naked Return)" }- 通过变量名直接告诉调用者返回值的含义,比单纯的类型(如
(int, int))更清晰
- 通过变量名直接告诉调用者返回值的含义,比单纯的类型(如
-
-
**函数也是值。它们可以像其他值一样传递。**函数值可以用作函数的参数或返回值(函数式变成)
匿名函数
匿名函数就是没有名字的函数。它可以被定义在另一个函数内部,并立即执行或赋值给一个变量
Go
func main() {
// 定义并立即执行
func(msg string) {
fmt.Println(msg)
}("Hello Go!")
}
定义了匿名函数他不会自己执行,必须我们配置一个()来进行调用执行,就算没有参数也是需要一个()的
函数的闭包
闭包 是一个函数值,它引用了其函数体之外的变量。该函数可以访问并赋予被引用变量的值,即使该变量已经超出了它的原始作用域
GoLang的闭包其实是Rust闭包的子集,简单使用而且不需要考虑那么多
Java 的 lambda 捕获局部变量要求变量 effectively final(捕的是值的快照,不准改);Go 闭包捕的是变量本身,能读能写。这是最大的区别
简单来说:闭包 = 函数 + 引用环境
Go
func makeMultiplier(factor int) func(int) int {
// factor 是外部变量
return func(n int) int {
return n * factor // 内部函数捕获了 factor
}//返回值是一个func(int) int的函数,因此只要这个函数没死,那么这里的factor也不会死
}
普通的函数中,局部变量在函数返回后就会被销毁。但在闭包中,**如果内部函数引用了外部函数的局部变量,Go 编译器会通过逃逸分析 (Escape Analysis) 将该变量分配到堆上,使其生命周期延长,直到闭包不再被使用(**因为一般函数都是在栈里面处理所有的变量的,但是闭包给了一种机制,让你栈里面的数据如果在外面会被用到,那么就会自动放到堆里面去存着,算一种语法糖)
Go
func main() {
var funcs []func()
for i := 0; i < 3; i++ {
funcs = append(funcs, func() {
fmt.Println(i) // 每个匿名函数都捕获了变量 i
})
}
for _, f := range funcs {
f() // 循环结束后再统一执行这些闭包
}
}
闭包捕获变量是按引用(Reference)进行的:因此上面这个例子打印的结果是3,3,3而不是按照值传递的1,2,3。这些闭包在创建时捕获的是同一个 i 的内存地址,所以当循环结束、它们真正开始执行时,都会去那个地址读取当时的值,也就是3。但是后来Go改变了这种设计:
Go ≤ 1.21 输出:3 3 3;Go ≥ 1.22 输出:0 1 2
-
老版本里,**i 是整个 for 循环共享的一个变量(一个地址)。三个闭包抓的都是同一个 i 的地址。**等循环结束,i 已经变成 3,三个闭包一起去读那个地址 → 全是 3。
-
Go 1.22 改了语义:每次迭代都创建一个全新的 i。三个闭包各抓各的那一轮的 i,互不干扰 → 0 1 2,这个也更符合我们的直觉
1.22 修的是「for 循环变量每轮复用」这一个特定 bug,不是「闭包捕获」这个机制。 闭包永远抓的是「变量本身」,只要多个闭包/goroutine 共享同一个变量
Go
sum := 0
funcs := []func(){}
for i := 0; i < 3; i++ {
sum += i
funcs = append(funcs, func() { fmt.Print(sum, " ") })
}
for _, f := range funcs { f() }
// 1.22 依然输出 3 3 3 ------ 因为 sum 是循环外的同一个变量,三个闭包抓的还是它
因为这里的sum不是循环里面的变量而是循环外面的变量,因此还是会显示相同的值的,因此如果变量不是循环变量的话,那么就必须要使用值传递的方案去修复了,比如下面的方案:
Go
sum := 0
funcs := []func(){}
for i := 0; i < 3; i++ {
sum += i
currentSum := sum // 每一轮创建一个新的局部变量
funcs = append(funcs, func() {
fmt.Print(currentSum, " ")
})
}
for _, f := range funcs {
f()
}
延迟执行函数 (defer)
-
主要作用是延迟执行,
defer语句后面的函数不会立即执行,而是会被推入一个栈中。只有当外层函数执行到return或者发生panic(崩溃)时,这些延迟函数才会被调用 -
如果一个函数里有多个
defer,它们会按照后进先出(Last In, First Out)的顺序执行 -
defer后面函数的参数值,在defer** 语句出现的那一刻**就已经确定了,而不是在函数真正执行时才计算- 这里需要注意,这个确定好的参数值是一个指针(一般是因为闭包)还是一个普通的值
Gofunc main() { i := 1 // 这一刻,Go 记住了 i 是 1 defer fmt.Println("直接传参:", i) i = 100 // 函数结束时,打印的是 1 还是 100? }defer后面的函数如果是带参数的(比如fmt.Println),Go 会在执行到defer这一行时,立刻完成参数的值拷贝 。就像拍了一张照片,之后i怎么变,照片里的数字都不会变Gofunc main() { i := 1 // 这一刻,Go 记住了 i 的"家庭地址" defer func() { fmt.Println("闭包捕获:", i) }()//因为是闭包,所以会记住的是i的地址而不是值 i = 100 // 函数结束时,它顺着地址去看,发现 i 已经变成 100 了 }defer func() { ... }()时,你调用的是一个没有参数 的函数(因为没有参数,在调用的那一刻,并没有任何值 被复制)。函数体内部的代码只有在最后执行时,才会顺着那根"线"去寻找变量i当前的值,所以才会导致记住的是地址不是值
defer 执行在 return 中间------return 不是原子的,它分两步。defer 夹在中间。
return x在Go里面被翻译成三步:
-
第 1 步:把返回值赋值(给返回变量)
-
第 2 步:执行所有 defer
-
第 3 步:真正返回到调用方
-
因此这样就有两种情况:
-
如果用了具名返回值,defer 可以在返回前把它改掉
Gofunc f() (result int) { // ← 返回值有名字:result result = 5 defer func() { result++ }() return result } // 返回 6 -
匿名返回值,defer 改不了:因为返回值没有名字
Gofunc g() int { result := 5 // ← 这个 result 是普通局部变量,自己起的名 defer func() { result++ }() return result } // 返回 5**函数体里的 result 只是你自己声明的一个普通局部变量,它跟返回槽是两个东西。**return result 会把 result 的值(5)拷贝进一个你无法用名字访问的匿名返回槽。之后 defer 改的是局部变量 result(改成 6),但返回槽里已经是拷过去的 5 了,跟局部变量脱钩→ 返回 5。
-
面向对象
结构体 (Struct)
在 Go 语言中,没有像 Java 或 Python 那样的 class。结构体 是我们将不同类型的数据组合在一起、构建复杂对象的唯一方式。它是面向对象编程在 Go 中的基石
-
结构体定义与实例化:
type User struct { Name string; Age int }-
导出与私有(可见性)
-
如果字段名首字母大写 (如
Title),它是导出的(Public),其他包的代码可以访问它 -
如果首字母小写 (如
price),它是私有的(Private),只能在当前包内使用
-
-
初始化
Gotype Vertex struct { X, Y int } var ( v1 = Vertex{1, 2} // 创建一个 Vertex 类型的结构体,必须按顺序赋值 v2 = Vertex{X: 1} // Y:0 被隐式地赋予零值 v3 = Vertex{} // X:0 Y:0 p = &Vertex{1, 2} // 创建一个 *Vertex 类型的结构体(指针) )- 可以使用
Name:语法可以初始化列出部分字段,剩下的走默认值(或者零值)
- 可以使用
-
直接使用
.号来进行获取,Go删除了->访问指针的方式,而是走了Java那种指针和值统一的方式 -
结构体嵌套和字段提升
Gotype Admin struct { User // 匿名嵌套,Admin 自动拥有了 User 的所有字段 Level int }
如果你在嵌套时不给字段起名字 ,它就变成了匿名成员。**然后你可以直接通过
admin.Name访问User里的成员,而不需要写成admin.User.Name。**这让 Go 的组合(Composition)用起来非常像继承-
当我们在结构体中使用匿名嵌套(Embedding)时,确实可能会遇到"同名冲突"的情况。这种现象通常被称为字段遮蔽(Field Shadowing)
-
当你通过
admin.Name访问时,Go 会优先寻找Admin自身定义的字段。内部的字段并没有消失,只是被"藏"起来了,你仍然可以通过完整的路径admin.User.Name访问它 -
如果一个结构体同时嵌套了两个结构体(比如
S1和S2),且它们都有一个字段叫X,而外部结构体没有定义X:-
当你尝试访问
obj.X时,Go 编译器会报错,提示ambiguous selector(歧义选择器) -
编译器不会帮你猜你想用哪一个,它要求你必须显式指定路径,如
obj.S1.X或obj.S2.X
-
-
-
-
结构体方法 (Methods) :
func (u User) SayHi() { ... }-
结构体方法是需要进行类型的绑定的,也就是这里的
(u User) -
指针接收者 vs 值接收者
-
值接收者 ****
(u User):方法执行时会得到 User 的一个副本 (拍照)。你在方法里修改属性,不会影响原来的对象 -
指针接收者 ****
(u *User):方法执行时拿到的是地址 (钥匙)。你在方法里修改属性,原对象会跟着变
-
-
-
结构体标签(Struct Tag):
-
在 Go 里面,结构体的字段名通常是大写开头的(为了跨包可见),比如
UserName。但前端的 JSON 可能要求小写user_name,或者数据库里叫u_name,因此需要建立一种映射关系,用来告诉框架一些映射的信息 -
标签写在字段定义的后面,用反引号 ` 包裹。格式一般都是 **
key:"value"**的形式C++type User struct { // json: 定义 JSON 序列化的名称 // binding: 定义 Gin 的参数校验规则 Name string `json:"user_name" binding:"required"` Age int `json:"age" binding:"gte=1,lte=120"` }
-
接口(interface{})
接口是 Go 语言实现解耦 和多态 的核心工具。与结构体不同,接口不关心对象"是什么"(数据),而只关心对象"能做什么"(行为)
-
接口是一组方法签名的集合。如果一个结构体实现了接口中要求的所有方法,我们就说这个结构体"实现"了该接口
Go// 定义接口 type Speaker interface { Speak() string } // 结构体 A:狗 type Dog struct{} func (d Dog) Speak() string { return "汪汪!" } // 结构体 B:机器人 type Robot struct{} func (r Robot) Speak() string { return "你好,人类。" }这里就会说Dog和Robot这两个结构体都实现了接口Speaker
Gofunc Announce(s Speaker) { fmt.Println("广播内容:", s.Speak()) }这样我们可以将接口当作参数传入函数里面,这样只要实现了这个接口的结构体对象就都可以调用这个函数了
-
空接口
interface{}:空接口是所有结构体都实现了的接口-
fmt.Println为什么能打印任何东西?因为它接收的就是...interface{} -
当你想接收某些数据但是不知道其类型的时候,那么就可以使用空接口来承接
-
类型断言(Type Assertion)
空接口能装任何东西,但当你把它拿出来用时,由于它是"隐形的",编译器不知道它到底是什么,这个时候就需要使用类型断言来"拆箱"
Go
var x interface{} = 10
// ❌ 报错:invalid operation: x + 5 (mismatched types interface{} and int)
// fmt.Println(x + 5)
// ✅ 必须断言
if v, ok := x.(int); ok {
fmt.Println(v + 5) // 正常输出 15
}
我们尝试对x这个对象转换成int,如果成功了的话,那么这个ok就是true,那么我们就可以直接将anythong当作int来使用。如果不写这个断言进行拆箱那么我们就不可以将其当作int使用(哪怕其其实本身是可以当作int使用的)
接口的组合
它允许我们将多个微小的接口,"组合"成一个功能更强大的新接口
Go
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
// *ReadWriter 组合了 Reader 和 Writer*
type ReadWriter interface {
Reader
Writer
}
任何实现了 Read 和 Write 方法的结构体,都会自动同时满足 Reader 、Writer 和 ReadWriter 这三个接口
接口的具体应用
Java
func OrderDone(n Notifier) { // 参数是接口
n.Send("您的订单已完成 ✅")
}
func main() {
OrderDone(SMSSender{}) // 📱 短信: 您的订单已完成 ✅
OrderDone(EmailSender{}) // 📧 邮件: 您的订单已完成 ✅
OrderDone(WechatSender{}) // 💬 微信: 您的订单已完成 ✅
}// OrderDone只关心一点就是你能不能 Send,有没有这个能力,只要有就可以了
/// 然后我们就只需要对应的结构体都有这个Send这个方法就可以了
type SMSSender struct{}
func (s SMSSender) Send(msg string) { fmt.Println("📱 短信:", msg) }
type EmailSender struct{}
func (e EmailSender) Send(msg string) { fmt.Println("📧 邮件:", msg) }
type WechatSender struct{}
func (w WechatSender) Send(msg string) { fmt.Println("💬 微信:", msg) }
解耦的回报:新增能力靠「加类型」,而不是「改逻辑」。这里就起到了一个一次写好多次扩展的能力,以后需要添加一种新的方式来处理订单我们只需要那个结构体有Send的能力即可
结构体(struct)是「数据是什么」,接口(interface)是「能做什么」。
嵌入
Java 里一个 extends 同时承担了两个完全不同的职责------复用父类的代码结构+子类可以当作父类使用(多态)
Go 把这两件事拆成了两个独立机制:
-
① 代码复用 → 用组合 / 嵌入(composition / embedding)
-
② 多态 → 用接口(interface)
Go
type Animal struct {
Name string
}
func (a Animal) Eat() { fmt.Println(a.Name, "在吃东西") }
func (a Animal) Sleep() { fmt.Println(a.Name, "在睡觉") }
type Dog struct {
Animal // 👈 匿名嵌入,注意没有字段名!
}
func (d Dog) Bark() { fmt.Println(d.Animal.Name, "汪汪") }
把结构体当成一个字段塞进去,只是写的时候不写字段名,这个叫匿名嵌入,主要的目的就是为了少些一点调用的逻辑:
-
如果你写成具名字段 animal Animal,那你就得 d.animal.Eat()
-
但是如果没有写字段名那么Go 会自动做方法提升(method promotion),d.Eat() 就直接相当于d.Animal.Eat()
错误处理
Go 的错误处理非常直截了当:它不使用 try-catch 这种捕获异常的机制,而是坚持"错误也是一种值"。这意味着函数在返回结果的同时,通常也会返回一个 error 类型的值
Go
f, err := os.Open("filename.ext")
if err != nil {
// 处理错误(比如打印日志或返回给调用者)
return err
}
// 只有 err == nil 时,才安全地使用 f
try-catch会导致逻辑跳转变得难以追踪。通过将错误显式返回,Go 迫使开发者在错误发生的第一现场 决定如何处理它。这种"显式优于隐式"的做法让代码的路径非常清晰,但是也会导致写起来非常难受会有很多的 **if err != nil**
error 本身其实是一个接口。它的定义极其简单:
Go
type error interface {
Error() string
}
- 这意味着任何实现了
Error() string方法的结构体都可以作为一个错误
泛型
Go 1.18 引入的一个"重量级"特性:泛型 (Generics)
泛型允许我们在定义函数、结构体或接口时,先不指定具体的类型,而是使用一个类型形参 (Type Parameter)。你可以把它想象成给类型预留了一个"占位符"
Go
// [T any] 是泛型的核心。T 代表某种类型,any 表示它可以是任何类型。
func PrintAnything[T any](item T) {
fmt.Println(item)
}
泛型解决了代码冗余的问题,同时保持了类型安全
类型约束
如果你定义 [T any],那 T 确实可以是任何类型。但如果你想在函数里做一些特定的操作(比如比较大小 > 或相加 +),你就必须限制 T
Go自带一些类型约束比如comparable,但是我们也可以自定义类型约束
Go
type Number interface {
int | int64 | float64
}
// 只有满足 Number 接口的类型才能调用这个函数
func Add[T Number](a, b T) T {
return a + b
}//
Go
type MyInt int
type Ordered interface {
~int | ~float64 | ~string
}//~int。它的意思是:**不仅匹配 int,还匹配所有底层类型是 int 的自定义类型,这样MyInt就匹配了**
Go泛型实现的原理
Go 泛型是 Go 1.18(2022)引入的,比 Java 晚很多,实现是一种编译期为主的折中方案,介于「完全单态化」和「类型擦除」之间
-
用接口来表达约束,any 就是 interface{}。还引入了 ~(底层类型------int64的底层类型是int)和类型集合(type sets------可以使用集合运算|等符号来运算)来描述「哪些类型允许」
-
Go 编译器不是给每个具体类型都生成一份代码(那会代码爆炸),也不是完全擦除。它按「GCShape」分组:
-
所有指针类型共享同一份实例化代码(因为它们在 GC 和内存布局上形状相同)。
-
值类型则按各自的形状分别生成代码(如 int、float64、不同大小的 struct)。
-
-
对于共享同一份代码的那些类型(比如各种指针),编译器在调用时额外传入一个隐藏的「字典」参数,里面装着具体类型的元信息(类型描述符、需要调用的方法等),让同一份机器码能服务多个类型。因为指针虽然具体的内存格式是一样的,但是不知道这个指针的服务对象是谁**(比如*User进行共享之后就变成了*ptr,然后我们要是想去解析这个内存还需要User这个结构体信息,而这个元信息就是存的这个东西)**
并发编程
并发的核心哲学是:"不要通过共享内存来通信,而要通过通信来共享内存。"
Goroutine:轻量级"线程"------协程
-
**在其他语言(如 Java 或 C++)中,创建一个线程通常需要消耗几 MB 的内存。而在 Go 中,启动一个 Goroutine 只需几 KB。**这意味着你可以在一台普通的笔记本电脑上轻松运行数万个、甚至数十万个并发任务
-
启动方式极其简单,只需要在函数调用前加一个
go关键字:go doSomething() // 这个函数会异步执行,不会阻塞主流程- 当你调用一个函数时,它是同步 的;当你在它前面加个
go,它就变成了异步
- 当你调用一个函数时,它是同步 的;当你在它前面加个
-
**在 Go 中,
main函数运行在"主协程"上。一旦main执行结束,程序会立即退出,它不会等待其他后台协程执行完,因此我们需要等待的方式来让main**是最后结束的协程->想办法阻塞住main协程Govar wg sync.WaitGroup wg.Add(1) // 登记一个任务 go func() { defer wg.Done() // 任务结束减去 1 fmt.Println("任务完成") }() wg.Wait() // 阻塞住,直到计数器变回 0
通道 (Channel)
在 Go 里面,我们不希望协程之间通过修改同一个变量来交流(使用互斥锁是Java的设计哲学),而是希望它们像接力赛一样传棒
-
通道是有类型 的,你必须指定通道里传的是什么(int, string, 还是你的 User 结构体)
Goch := make(chan int) // 创建一个传递整数的通道,channel的关键字是chan来创建的而且需要使用,make函数来进行创建 // 发送数据 (发送者) go func() { ch <- 42 // 把 42 塞进通道 }() // 接收数据 (接收者) value := <-ch // 从通道里取出数据 fmt.Println(value) -
"阻塞"特性:通道不仅仅是数据的搬运工,它还是一个天然的同步器
-
发送阻塞:如果通道里没人接,发送方会一直等在那(直到有人取走)
-
接收阻塞:如果通道里没东西,接收方会一直等在那(直到有人送来)
-
channel 的核心不是「传数据」,而是「让 goroutine 互相等待」。 很多时候你要的不是管道里那个值,而是「塞/取」这个动作带来的阻塞和唤醒------也就是同步时序。
其实也就是我们常说的生产者消费者模型,变成了Go的一个关键字了。这种特性让你不需要写任何 Sleep,代码会自动在需要的时候"停下来"等对方
- 通道默认是没有缓冲的,有缓冲的需要单独声明
ch := make(chan int, 3)
Go
func main() {
ch := make(chan int)
go func() {
fmt.Println("子goroutine: 准备发送")
ch <- 42 // ① 塞进去,但没人接 → 卡在这
fmt.Println("子goroutine: 发送完成") // ④ 交接成功后才打印
}()
time.Sleep(time.Second) // 故意让主goroutine晚点来接
fmt.Println("主goroutine: 准备接收")
v := <-ch // ② 主来接了 → 此刻交接成功
fmt.Println("主goroutine: 收到", v) // ③
}
一般我们使用完成一个channel就需要进行关闭,但是有关channel关闭,有四种特殊情况:
因此我们发现是否有东西很难判断到底是零值还是真的没有东西呢,还是使用双返回值的方案:
Java
v, ok := <-ch
// ok == true → 真收到了数据
// ok == false → channel 已关闭,且肚子已空
-
ok 问的是「我这次到底捞着真东西没有?」。false = 没捞着真东西(因为关了且空了),true = 捞着了。你把它理解成「ch 是否关闭」就会方向反。别记成"是否关闭",记成"是否收到了真数据"。
-
这里如果通道已经关闭了的话那么就会返回对应channel的nil零值,如果chan int的话那就返回是0
Go的数据竞态
Go也是有数据竞态的情况的
Go
m := make(map[string]int)
go func() { m["a"] = 1 }()
go func() { m["b"] = 2 }()
map ** 在并发读写下是不安全的。** 只要有一个协程在写,其他协程无论是读还是写,都会触发 fatal error: concurrent map writes
-
方案 A:Java类型的守旧派 ------ 互斥锁 (Mutex)
Govar mu sync.Mutex mu.Lock() m["a"] = 1 mu.Unlock() -
方案 B:Go 派 ------ 通道 (Channel) 推荐方式
Goresults := make(chan struct{k string; v int}) // 协程只管发数据 go func() { results <- struct{k string; v int}{"a", 1} }() // 主协程(或专门的写入协程)负责收 res := <-results m[res.k] = res.v- 通道(Channel)其实就是 Go 在语言层面内置的一个"线程安全的阻塞队列" ,它是实现生产者-消费者模式的绝佳工具,而生产消费者模式就是我们避免数据竞态的绝佳的方式
select 语句
select 的语法看起来像 switch,但它的每一个 case 后面必须是一个通道操作
Go
select {
case msg1 := <-chan1:
fmt.Println("收到通道1的消息:", msg1)
case chan2 <- "hello":
fmt.Println("成功给通道2发送了消息")
default:
fmt.Println("目前没有任何通道准备好")
}
-
如果没有
default,select会一直等在那里,直到其中任意一个 case 的通道可以进行读写操作 -
如果同时有多个通道准备好了,Go 会随机选择一个执行。这保证了每一个通道都有公平的机会被处理,不会因为某个通道太忙而导致其他通道"饿死"
-
有了
default,select就会变成非阻塞模式。如果没有任何通道就绪,它会直接执行default里的逻辑,而不是在那死等 -
这就是Go的多路复用机制的实现------当前协程可以执行多个任务,但是不进行阻塞------只要有可以执行的就先执行
nil channel:读写都永久卡死,不报错
nil channel 收发不报错,直接永久卡死。有个高级用途:在 select 里把某个 case 的 channel 设成 nil,就能动态关掉那个分支
**如果 ch1 关了你不把它设 nil,那麻烦了------关闭的 channel 是立刻返回零值、不阻塞的,于是 case <-ch1 会疯狂被选中,每次都拿到 (0, false),CPU 空转。**把它设成 nil,正好把这个「关闭后狂转」的分支摘干净。
Go
func merge(ch1, ch2 <-chan int) {
for ch1 != nil || ch2 != nil { // 只要还有一个没被摘掉,就继续
select {
case v, ok := <-ch1:
if !ok { // ch1 已关闭且排空
ch1 = nil // ★ 关键:把 ch1 设成 nil → 这个 case 从此永远不就绪
continue // select 以后会自动跳过这个分支
}
fmt.Println("来自 ch1:", v)
case v, ok := <-ch2:
if !ok {
ch2 = nil // ★ 同理摘掉 ch2
continue
}
fmt.Println("来自 ch2:", v)
}
}
fmt.Println("两个 channel 都关完了,退出")
}
死锁
死锁 A:无缓冲 channel,自己发给自己(没有别的 goroutine 来接)
Go
func main() {
ch := make(chan int) // 无缓冲
ch <- 1 // 💥 卡死:主 goroutine 想发,但没有任何人来接
fmt.Println(<-ch) // 永远执行不到
}
// fatal error: all goroutines are asleep - deadlock!
死锁 B:缓冲满了,没人取
Go
func main() {
ch := make(chan int, 2)
ch <- 1 // 缓冲 [1]
ch <- 2 // 缓冲 [1,2],满了
ch <- 3 // 💥 卡死:缓冲满,等人来取腾位置,但没有别的 goroutine 会取
}
Context (上下文)
Context 是一个接口(需要引入context包),它最常用的功能可以概括为:
-
WithCancel:手动取消。发出一声令下,所有监听这个 Context 的协程全部停止。 -
WithTimeout** / ****WithDeadline**:自动取消。比如规定"这个请求最多跑 5 秒",超时自动触发取消信号。 -
WithValue:传递元数据。在协程链路中传递请求 ID、用户信息等
用户点击"搜索机票",后端接收到请求,启动了 3个并发任务:去国航查、去南航查、去东航查。
突然,用户觉得网速慢,点了一下"取消"或者直接关闭了浏览器 。Context就是为了应对这种情况,用户取消了连接就没有必要继续执行子任务了(尤其是这种没有接收的死任务)
Go
package main
import (
"context"
"fmt"
"time"
)
func main() {
// 1. 创建一个"2秒后自动过期"的上下文
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()// 在函数结束时释放资源
// 2. 启动一个干活的协程
go doHardWork(ctx)
// 3. 主程序等一会儿,看看结果
time.Sleep(3 * time.Second)
fmt.Println("主程序结束")
}
func doHardWork(ctx context.Context) {
for {
select {
case <-time.After(5 * time.Second):
// 假设这个活儿要干 5 秒
fmt.Println("任务终于干完了!")
return
case <-ctx.Done():
// 重点!这里一直在监听"超时信号"
// 一旦 2 秒时间到,这里就会收到信号
fmt.Println("【救命】收到指挥部撤退命令,任务太久了,我不干了!")
return
}
}
}
Go 的并发模型是「随手 go 一个 goroutine」,但 **goroutine 之间没有父子关系、没有「杀掉子线程」的机制。**context不是去「杀」goroutine,而是给所有 goroutine 发一个「请你自己退出」的广播信号,让每个 goroutine 自觉收手。「取消信号广播」本质就是「close 一个 channel」
原理
Go
type Context interface {
Done() <-chan struct{} // ★ 核心:返回一个 channel
Err() error // 取消的原因
Deadline() (time.Time, bool)
Value(key any) any // 携带值
}
Done() 返回的 channel 就是整个机制的命门。平时这个 channel 开着且没数据 → <-ctx.Done() 一直阻塞。一旦取消 / 超时发生,context 内部把这个 channel close 掉。
Go
func worker(ctx context.Context) {
for {
select {
case <-ctx.Done(): // 一旦 ctx 被取消,这个 case 立刻就绪
fmt.Println("收到取消信号,退出:", ctx.Err())
return // 自觉收手
case task := <-taskCh:
process(task)
}
}
}
一旦ctx被取消,那么就是一个立刻返回零值、不阻塞的操作,因此这个关闭的逻辑就可以开始执行了
GMP 模型
GMP 模型(G=goroutine、M=OS线程、P=处理器)
Go Modules(包管理器)
通过两个文件来锁定你的项目依赖:
-
go.mod:定义模块路径和你依赖的库列表 -
go.sum:记录依赖库的校验和,确保安全性,防止你下载的代码被中途掉包
Go的包管理器和Rust的Cargo很类似,只不过没有一个统一管理依赖的网站create.io
-
**
go mod init <模块名>****:**初始化项目。模块名通常是你的 GitHub 仓库地址,比如go mod init ``github.com/user/myproject。 -
**
go get <包名>****:**下载第三方库。go get -u ``github.com/gin-gonic/gin。**它会自动下载并把版本信息写进 **go.mod -
go mod tidy****:代码大扫除 。它会扫描你的代码,自动把没用到的包删掉,把代码里 import 了但还没下载的包补上
Go的目录规范:
Python
my-project/
├── cmd/ # 程序入口(Main 函数所在)
│ └── my-app/ # 每个子目录代表一个编译出的二进制文件
│ └── main.go
├── internal/ # 私有包(不想被别人引用的代码)
│ ├── auth/ # 内部认证逻辑
│ └── db/ # 内部数据库操作
├── pkg/ # 公共库(可以被其他项目引用的代码)
├── api/ # API 定义文件(如 Swagger, Proto 文件)
├── configs/ # 配置文件模板或默认配置
├── deployments/ # 部署相关(Docker, Kubernetes)
├── scripts/ # 脚本文件(编译、工具等)
├── test/ # 额外的外部测试数据和测试脚本
├── go.mod # 依赖管理
└── go.sum # 依赖校验
反射
反射赋予了程序在运行时(Runtime)检查、修改自身状态和类型的能力
Go 的反射主要由 reflect 包提供,它围绕两个核心支柱展开:
-
reflect.Type:它是关于"我是谁"的信息(类型名、字段、方法等) -
reflect.Value:它是关于"我值多少"的信息(实际的数据内容、可以对其进行读写)
Go
package main
import (
"fmt"
"reflect"
)
type User struct {
Name string `validate:"min:3"` // 这里的标签是反射能读到的核心
Age int
}
func ValidateStruct(s interface{}) {
v := reflect.ValueOf(s)
t := reflect.TypeOf(s)
// 如果传进来的是指针,我们需要拿到它指向的具体内容
if t.Kind() == reflect.Ptr {
v = v.Elem()
t = t.Elem()
}
// 遍历所有字段
for i := 0; i < t.NumField(); i++ {
field := t.Field(i) // 拿到字段类型信息(包含 Tag)
value := v.Field(i).String() // 拿到字段实际的值
tag := field.Tag.Get("validate") // 获取标签内容
if tag == "min:3" && len(value) < 3 {
fmt.Printf("错误:字段 [%s] 的长度太短了!当前值: %s\n", field.Name, value)
}
}
}
func main() {
u := User{Name: "Go", Age: 10}
ValidateStruct(u) // 这里的 Name 只有 2 位,不符合 min:3
}
-
反射性能和普通代码性能差距通常在 10 到 100 倍 之间
-
Go 强大的类型检查在反射面前完全失效。如果你写错了字段名,编译器不会报错,程序运行到那一刻直接
panic崩溃 -
代码维护起来困难------需要大量的类型断言等语句
Go语言社区的标准就是,除了少数必须使用的基建设施------比如序列化JSON/XML等,基本不要使用反射来构建代码,这也是和Java的最重要的区别**(Go希望所有的细节都是显式的,不用IOC这些黑盒一样的东西,Java的开发速度是很快的,因为有注解这些魔法一样的东西,但是因为所有的都不是显式出来的,因此可能导致很多底层的问题是没有办法轻易修复的)**
Web框架------Gin
路由与基础请求
路由(Routing)决定了哪个 URL 路径由哪段代码来处理。它支持标准的 HTTP 方法(GET, POST, PUT, DELETE)
静态路由、参数路由、通配符路由
-
Gin 的路由分为三类:静态路由 、参数路由 和通配符路由
-
**静态路由:**路径固定,一对一匹配
Gor := gin.Default() r.GET("/ping", func(c *gin.Context) { c.JSON(200, gin.H{"message": "pong"}) }) r.POST("/submit", func(c *gin.Context) { c.String(200, "数据已提交") }) -
**参数路由 (Path Parameters)😗*通过
:name这种语法定义动态片段Gor.GET("/user/:id", func(c *gin.Context) { // 使用 c.Param 获取 URL 路径中的变量 id := c.Param("id") c.String(200, "正在查询用户 ID 为: %s", id) })
如果同时出现静态路由
/user/admin和参数路由/user/:id,访问/user/admin时会进入静态路由,因为其优先级更高
3. 通配符路由 (Catch-all):使用*filepath。它会匹配该路径下的所有内容,通常用于处理静态文件服务Gor.GET("/assets/*filepath", func(c *gin.Context) { path := c.Param("filepath") c.String(200, "你正在访问资源路径: %s", path) }) -
路由分组
-
路由可以进行分组------使用Group函数来进行分组
Gofunc main() { r := gin.Default() // v1 版本的 API 分组 **v1 := r.Group("/v1")** { v1.GET("/login", loginHandler) v1.GET("/submit", submitHandler) } // v2 版本的 API 分组,甚至可以嵌套 v2 := r.Group("/v2") { user := v2.Group("/user") { user.GET("/info", infoHandler) // 实际访问路径: /v2/user/info } } r.Run() }
参数处理与数据绑定
获取数据主要分为两种风格:一种是手动提取 (适合简单场景),另一种是自动绑定(最强杀手锏,适合复杂业务)
手动绑定
-
获取 Query 参数(URL 问号后面的):****
?id=100&name=gemini-
c.Query("name"):获取参数 -
c.DefaultQuery("page", "1"):获取参数,如果没传就给个默认值
-
-
获取 Form 表单参数(POST 提交)
c.PostForm("username")
自动绑定
ShouldBind它可以自动根据 Content-Type(JSON、XML、YAML、Form)将数据填入你的 Struct 中**(这个比Java清晰太多了)**
Go
type LoginRequest struct {
// tag 里的 json 代表 JSON 字段名,binding:"required" 代表必填
User string `json:"user" binding:"required"`
Password string `json:"password" binding:"required,min=6"`
}
func LoginHandler(c *gin.Context) {
var login LoginRequest
// ShouldBindJSON 会自动检查 Header 并解析 Body 里的 JSON
if err := c.ShouldBindJSON(&login); err != nil {
// 如果字段缺失或长度不够,err 会有详细信息
c.JSON(400, gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{"status": "登录成功", "welcome": login.User})
}
自动绑定是依赖于结构体Tag的补充信息的,而Gin主要有三类标签:
参数校验
Gin 内置了 validator 库,你可以直接在标签里写逻辑来进行参数校验,参数之间用逗号进行分隔
-
必填 :
binding:"required" -
范围 :
binding:"min=10,max=20" -
格式 :
binding:"email"(必须符合邮箱格式) 或binding:"url"(必须是有效的 URL 地址) -
跨字段校验 :
binding:"eqfield=Password"(比如确认密码必须等于密码字段)
中间件------拦截器+AOP
中间件的固定格式就是一个接受 *gin.Context 的函数
Go
// 这是一个最简单的打印日志的中间件
func MyMiddleware(c *gin.Context) {
fmt.Println("我是中间件:请求进来了,我要先查一下你的身份")
// 中间件的核心逻辑:c.Next() 代表放行,让后面的业务逻辑跑起来
c.Next()
fmt.Println("我是中间件:业务逻辑跑完了,我最后再收个尾")
}
-
c.Next():暂停当前代码,先去跑后续的业务逻辑,跑完后再回来 -
这个一般都是用来构建类似Java的Spring里面的AOP(面向切面编程)的东西(比如拦截器等等)
-
我们使用
r.Use(MyMiddleware)这类来进行配置到gin.Engine中去
然后我们需要配置使用逻辑,在哪里用:
-
全局拦截
Gor := gin.Default() r.Use(MyMiddleware) // 只要是发到这个服务器的请求,都会被它拦截 -
给特定的分组用(最常用)
JavaScriptadmin := r.Group("/admin") admin.Use(MyMiddleware) // 只有 /admin/xxx 的请求会被拦截 { admin.GET("/dashboard", func(c *gin.Context) { c.String(200, "这是后台看板") }) } -
给单个接口用
Gor.GET("/secret", MyMiddleware, func(c *gin.Context) { c.String(200, "这是秘密内容") })
GORM
这是Go最常用的持久化ORM框架,和Java里面的MyBatis基于XML的配置不一样,GROM是基于结构体Tag的(又Win了)
Go
type User struct {
// gorm.Model 内部包含了 ID, CreatedAt, UpdatedAt, DeletedAt
// 这是 GORM 的"全家桶"配置,推荐带上
gorm.Model
Name string `gorm:"column:user_name;type:varchar(100);unique"`
Age int `gorm:"default:18"`
Email string `gorm:"index"` // 自动创建索引
}
gorm:"column:..."** 告诉程序:这个字段在数据库里叫什么、是什么类型、有没有索引**
连接数据库
Go 的数据库驱动通常是"插件化"的。你需要导入 MySQL 驱动**,但代码里直接用 GORM 的接口**
Go
// 格式:用户名:密码@tcp(地址:端口)/数据库名?参数1=值1&参数2=值2
dsn := "root:123456@tcp(127.0.0.1:3306)/my_db?charset=utf8mb4&parseTime=True&loc=Local"
-
?charset=utf8mb4:使用支持表情符号的 UTF-8 编码 -
parseTime=True:告诉 GORM 把数据库里的datetime字段自动转成 Go 的time.Time类型。如果不加这一句,读时间会报错 -
loc=Local:使用本地时区 -
如果你的密码里有
@、:或/(比如pass@word),直接写在 DSN 里会破坏格式导致解析失败。需要对特殊字符进行 URL 编码(比如@变成%40)
Go
sqlDB, _ := db.DB()
// 设置最大空闲连接数
sqlDB.SetMaxIdleConns(10)
// 设置最大打开连接数
sqlDB.SetMaxOpenConns(100)
// 设置连接最大存活时间
sqlDB.SetConnMaxLifetime(time.Hour)
拿到 db 对象后,通常要设置一下连接池
增删改查操作
Go
// 需要先配置好dsn
// 连接数据库
db, _ := gorm.Open(mysql.Open(dsn), &gorm.Config{})
// 自动迁移(非常爽):它会自动检查你的 Struct,然后在数据库里建好对应的表
db.AutoMigrate(&User{})
AutoMigrate非常好用:
-
自动创建:如果表不存在,它会根据结构体名(自动转复数)建表
-
增量更新 :如果你在
User里新加了一个Age int字段,再次运行AutoMigrate,它会执行ALTER TABLE users ADD COLUMN age -
索引维护 :你在标签里写的
uniqueIndex,它也会帮你创建 -
**不会删除列,不会修改列类型:**一旦类型没有匹配上会报错的,需要进行手动SQL修改维护
-
增 (Create):
Gouser := User{Username: "Gemini", Password: "123"} result := db.Create(&user) // 传入指针 // result.Error 可以看有没有报错 // result.RowsAffected 可以看影响了几行 -
查 (Query):
Javavar u User // 查询第一条(按 ID 排序) db.First(&u) // 根据条件查一条 db.Where("username = ?", "Gemini").First(&u) // 查询全部 var users []User db.Find(&users) -
改 (Update):
Go// 方式1:更新单个字段 db.Model(&u).Update("Password", "new_password") // 方式2:更新多个字段(通过 map 或 结构体) db.Model(&u).Updates(User{Username: "NewName", Password: "666"}) -
删 (Delete):
Godb.Delete(&u) // 如果模型里有 DeletedAt(gorm.Model 自带),这只是"软删除",数据还在,只是查不出来了。没有就真删了