Golang Web3 Development Book

Welcome to the world of decentralized blockchain.

This book will guide you through interacting with Ethereum and EVM-compatible chains using Golang and the go-ethereum SDK:

  • querying on-chain data (accounts, blocks, transactions, events)
  • sending transactions and calling smart contracts
  • parsing contract storage slots
  • merkle trees, signatures, and building a private POA chain

This book is not for complete beginners.

requires: base solidity knowledge

Alright, let's get started!

  1. This book is hosted on GitHub: https://github.com/yuhuajing/web3-development-with-go-book
  2. Page: https://yuhuajing.github.io/web3-development-with-go-book/index.html

Initialization

初始化参数

  • golang 程序变量的初始化和变量的初始依赖相关
    • 初始化过程会存在多轮周期,每轮周期只初始化没有任何外部依赖的变量
    • 初始化过程一直持续到全部变量完成初始化
    • 如果依赖关系形成循环,编译器会直接报错(如 initialization cycle)
    • 包级别的初始化在 main 函数执行之前、由主 goroutine 串行完成,因此初始化函数可以安全地使用 goroutine,但被启动的 goroutine 要等初始化全部结束后才会被调度
var (
	a int = b + 1
	b int = 1
)

func main() {
	fmt.Println(a) // 2  第二轮初始化
	fmt.Println(b) // 1  第一轮初始化
}

初始化顺序:第一轮先初始化没有依赖的 b = 1;第二轮再计算依赖 b 的 a = b + 1 = 2。声明顺序在前不代表初始化在前,依赖关系才是决定因素。

init

  • init() 在包函数被执行前以及包内变量初始化后被调用
    • 变量初始化 -> init() -> main()
    • 每个包可以包含多个 init() 函数,同一个文件也可以有多个,按声明顺序执行
  • init 多用于初始化全局变量,先于 main 函数执行,并且不能被其他函数调用
    • init 函数没有传参和返回值,仅用来初始化当前包内的参数
    • init 的执行顺序和包的引入顺序相关:先初始化被导入的包,再执行当前包的 init
  • import _ "net/http/pprof" 表示仅导入包的 init 函数,不使用包内其他导出内容

完整执行顺序可总结为:

导入包的包级变量初始化 -> 导入包的 init() -> (递归向上) -> 当前包变量初始化 -> 当前包 init() -> main()

Example:

  1. 先执行全部变量的初始化
  2. 再执行 init 函数
  3. 最后执行主函数
var T int64 = a()

func init() {
	fmt.Println("init in main.go ")
}

func a() int64 {
	fmt.Println("calling a()")
	return 2
}

func main() {
	fmt.Println("calling main")
}
/**calling a()
init in main.go 
calling main**/

输出印证了顺序:a() 在变量初始化阶段最先被调用(即使它在 init 之后声明),然后是 init,最后才是 main。

数组和切片

数组是固定长度的值类型,切片则是基于数组的动态长度引用类型(底层由指针、长度、容量三部分构成)。Go 开发中绝大多数场景使用切片;理解两者的传参差异(值传递 vs 引用传递)和切片扩容机制,是避免隐性 bug 的关键。

数组

var a [3]int                    // 定义长度为3的int型数组, 元素全部为0
var b = [...]int{1, 2, 3}       // 定义长度为3的int型数组, 元素为 1, 2, 3
var c = [...]int{2: 3, 1: 2}    // 定义长度为3的int型数组, 元素为 0, 2, 3
var d = [...]int{1, 2, 4: 5, 6} // 定义长度为6的int型数组, 元素为 1, 2, 0, 0, 5, 6
  • 第一种方式是定义一个数组变量的最基本的方式,数组的长度明确指定,数组中的每个元素都以零值初始化。
  • 第二种方式定义数组,可以在定义的时候顺序指定全部元素的初始化值,数组的长度根据初始化元素的数目自动计算。
  • 第三种方式是以索引的方式来初始化数组的元素,因此元素的初始化值出现顺序比较随意。数组的长度以出现的最大的索引为准,没有明确初始化的元素依然用0值初始化。
  • 第四种方式是混合了第二种和第三种的初始化方式,前面两个元素采用顺序初始化,第三第四个元素零值初始化,第五个元素通过索引初始化,最后一个元素跟在前面的第五个元素之后采用顺序初始化。

值传递

当一个数组变量被赋值或者被传递的时候,实际上会复制整个数组。如果数组较大的话,数组的赋值也会有较大的开销。

  • copy 关键字可以完成值传递,另辟一片新的内存空间给新值

引用传递

  • 为了避免复制数组带来的开销,可以传递一个指向数组的指针,但是数组指针并不是数组,而是指向同一片内存区间的指针
  • 切片赋值,直接用切片赋值,传递的也是指针
  • 引用传递实际指向的是同一片内存空间,因此相同引用的变量之间会受到互相更新的影响
func main() {
  var a = [...]int{1, 2, 3} // a 是一个数组
  var b = a                 // b 是值传递赋值数组
  var c = a[:2]             // c 是引用传递的切片数组
  var d = &a                // d 是指向源数组的指针
  fmt.Println(a, b, c, d)   // 打印数组元素
  //[1 2 3] [1 2 3] [1 2] &[1 2 3]
  a[0] = 6
  fmt.Println(a, b, c, d)
  //[6 2 3] [1 2 3] [6 2] &[6 2 3]
  b[0] = 7
  fmt.Println(a, b, c, d)
  //[6 2 3] [7 2 3] [6 2] &[6 2 3]
  c[1] = 8
  fmt.Println(a, b, c, d)
  //[6 8 3] [7 2 3] [6 8] &[6 8 3]
  d[2] = 9
  fmt.Println(a, b, c, d)
  //[6 8 9] [7 2 3] [6 8] &[6 8 9]
}

String

值传递

直接赋值或通过切片赋值都是值传递,都会新开辟一段内存空间给新值

	s := "hello, world"
	h := s[:5]
	fmt.Println(s, h)
	//hello, world hello
	s = "ertgg"
	fmt.Println(s, h)
	//ertgg hello
	h = "asd"
	fmt.Println(s, h)
	//ertgg asd

通过指针引用内存

	s := "hello, world"
	h := &s
	fmt.Println(s, *h)
	//hello, world hello
	s = "ertgg"
	fmt.Println(s, *h)
	//ertgg ertgg

[]Rune

fmt.Printf("%#v\n", []rune("世界"))      		// []int32{19990, 30028}
fmt.Printf("%#v\n", string([]rune{'世', '界'})) // 世界
  • 这里的 rune 只是 int32 类型的别名,并不是重新定义的类型。
  • string 是只读的 byte 数据,string 字符使用 utf-8 编码,每个字符占位 1~3 bytes
  • 对于英文字符,string 和 rune 类型没有区别
  • 对于中文字符,rune 类型占位 4 bytes 字符,用于操作中文字符不会出现乱码

Examples:

// string & rune compare,
package main

import "fmt"

// string & rune compare,
func stringAndRuneCompare() {
  // string,
  s := "hello你好"

  fmt.Printf("%s, type: %T, len: %d\n", s, s, len(s))
  fmt.Printf("s[%d]: %v, type: %T\n", 0, s[0], s[0])
  li := len(s) - 1 // last index,
  fmt.Printf("s[%d]: %v, type: %T\n\n", li, s[li], s[li])

  // []rune
  rs := []rune(s)
  fmt.Printf("%v, type: %T, len: %d\n", rs, rs, len(rs))
}

func main() {
  stringAndRuneCompare()
}

OutPut:

hello你好, type: string, len: 11

s[0]: 104, type: uint8

s[10]: 189, type: uint8

[104 101 108 108 111 20320 22909], type: []int32, len: 7

Analysis:

  1. hello你好 占位 11 bytes(5 * 1 + 2 * 3 = 11)
  2. string 转 rune 时,一共 7 个 utf-8 字符,因此转换成 size = 7 的 []rune

数组切片

切片可以和 nil 进行比较,只有当切片底层数据指针为空时切片本身为 nil

type SliceHeader struct {
	Data uintptr
	Len  int
	Cap  int
}

数组切片由三部分组成:

  • 内存引用地址
  • 长度,返回切片中有效元素的长度
  • 容量,返回切片容量大小,容量必须大于或等于切片的长度,在容量使用完后会进行扩容

定义方式

var (
	a []int               // nil切片, 和 nil 相等, 一般用来表示一个不存在的切片
	b = []int{}           // 空切片, 和 nil 不相等, 一般用来表示一个空的集合
	c = []int{1, 2, 3}    // 有3个元素的切片, len和cap都为3
	d = c[:2]             // 有2个元素的切片, len为2, cap为3
	e = c[0:2:cap(c)]     // 有2个元素的切片, len为2, cap为3
	f = c[:0]             // 有0个元素的切片, len为0, cap为3
	g = make([]int, 3)    // 有3个元素的切片, len和cap都为3
	h = make([]int, 2, 3) // 有2个元素的切片, len为2, cap为3
	i = make([]int, 0, 3) // 有0个元素的切片, len为0, cap为3
)
  • 切片定义-- 数组不定长
    • 通过 make 分配内存,返回的是引用类型本身
      • make是生成一个可变大小的内存块,并返回一个它的引用
    • 通过 new 分配内存,返回的是指向类型的指针,并且内存置为0
      • new 可以申请任何类型变量内存块并返回一个指针指向它

值传递

copy 复制切片数组

  • copy 值传递,实现单独的内存分配,复制出的切片和原切片处于独立的内存区,两部分数据互不影响
  • copy(dst, src) 从源数据拷贝 min(len(dst), len(src))个元素
  • 因此,拷贝的 dst 数组需要初始化长度
	a := make([]int, 0, 10)
    a = append([]int{1, 2}, a...)    //在开头添加1个切片
    a = append(a, []int{3, 4, 5}...) // 在最后插入切片

    var b = make([]int, len(a)-1) //
    copy(b[:], a[:])
    fmt.Println(a, b)
    //[1 2 3 4 5] [1 2 3 4]
    a[0] = 9
    b[1] = 10
    fmt.Println(a, b)
    //[9 2 3 4 5] [1 10 3 4]

切片通过 append 在末尾增加数据

  • 切片之间通过 append 值传递,两部分数据互不影响
  • append 时没必要初始化新切片的 len, append 时会自动将数据依次加到新切片末尾
	a := make([]int, 0, 6) //默认0值为空 容量3
	a = append(append(append(a, 1), 2), 3)
	fmt.Printf("alen=%d acap=%d aslice=%v \n", len(a), cap(a), a)
	// alen=3 acap=6 aslice=[1 2 3]
	b := make([]int, 0) //声明一个长度为a 切片指针变量b
	b = append(b, a...)
	fmt.Printf("alen=%d acap=%d aslice=%v blen=%d bcap=%d bslice=%v \n", len(a), cap(a), a, len(b), cap(b), b)
	//alen=3 acap=6 aslice=[1 2 3] blen=3 bcap=3 bslice=[1 2 3]

	// append赋值值传递,两部分数据互不影响
	a[0] = 99
	fmt.Printf("alen=%d acap=%d aslice=%v blen=%d bcap=%d bslice=%v \n", len(a), cap(a), a, len(b), cap(b), b)
	//alen=3 acap=6 aslice=[99 2 3] blen=3 bcap=3 bslice=[1 2 3]
	b[1] = 100
	fmt.Printf("alen=%d acap=%d aslice=%v blen=%d bcap=%d bslice=%v \n", len(a), cap(a), a, len(b), cap(b), b)
	//alen=3 acap=6 aslice=[99 2 3] blen=3 bcap=3 bslice=[1 100 3]

赋值/切片 引用传递

容量

定义新切片时,默认采用旧切片的容量,因此已经提前分配好一片内存空间

在未超过原始内存的容量区间内,两个切片是引用传递

	// -------------------- 切片 直接引用 复制 -----------------
	a := make([]int, 0, 6) //默认0值为空 容量3
	a = append(append(append(a, 1), 2), 3)
	fmt.Printf("alen=%d acap=%d aslice=%v \n", len(a), cap(a), a)
	// alen=3 acap=6 aslice=[1 2 3]

	// 引用复制,在未超过旧切片的 cap 时,两部分指向相同的地址
	b := a
	fmt.Printf("alen=%d acap=%d aslice=%v blen=%d bcap=%d bslice=%v \n", len(a), cap(a), a, len(b), cap(b), b)
	//alen=3 acap=6 aslice=[1 2 3] blen=3 bcap=3 bslice=[1 2 3]

	a[0] = 99
	fmt.Printf("alen=%d acap=%d aslice=%v blen=%d bcap=%d bslice=%v \n", len(a), cap(a), a, len(b), cap(b), b)
	//alen=3 acap=6 aslice=[99 2 3] blen=3 bcap=6 bslice=[99 2 3]
	b[1] = 100
	fmt.Printf("alen=%d acap=%d aslice=%v blen=%d bcap=%d bslice=%v \n", len(a), cap(a), a, len(b), cap(b), b)
	//alen=3 acap=6 aslice=[99 100 3] blen=3 bcap=6 bslice=[99 100 3]

	// 再次新增数据,超出旧切片的容量
	b = append(append(append(b, 4), 5), 6)
	fmt.Printf("alen=%d acap=%d aslice=%v blen=%d bcap=%d bslice=%v \n", len(a), cap(a), a, len(b), cap(b), b)
	//alen=3 acap=6 aslice=[99 100 3] blen=6 bcap=6 bslice=[99 100 3 4 5 6]

	a[2] = 101
	b[1] = 98
	fmt.Printf("alen=%d acap=%d aslice=%v blen=%d bcap=%d bslice=%v \n", len(a), cap(a), a, len(b), cap(b), b)
	//alen=3 acap=6 aslice=[99 98 101] blen=6 bcap=6 bslice=[99 98 101 4 5 6]

	// a 也扩容到相同的内存空间
	a = append(a, 7)
	fmt.Printf("alen=%d acap=%d aslice=%v blen=%d bcap=%d bslice=%v \n", len(a), cap(a), a, len(b), cap(b), b)
	//alen=4 acap=6 aslice=[99 98 101 7] blen=6 bcap=6 bslice=[99 98 101 7 5 6]

    b = append(append(b, 7), 8)
    a[0] = 555
    b[1] = 666
    fmt.Printf("alen=%d acap=%d aslice=%v blen=%d bcap=%d bslice=%v \n", len(a), cap(a), a, len(b), cap(b), b)
    //alen=4 acap=6 aslice=[555 98 101 7] blen=7 bcap=12 bslice=[99 666 101 7 6 7 8]

切片扩容

当切片超出 cap 容量的时候,就会重新分配内存扩容,此时,两部分切片将不在引用同一个内存区间

	array := [6]int{0, 1, 2, 3, 4, 5}
	slice := array[1:3:4]
	fmt.Println("slice:", slice)
	//slice: [1 2]

	slice = append(slice, 6)
	fmt.Println("slice after appending 6:", slice, unsafe.SliceData(slice))
	fmt.Println("array now:", array)
	//slice after appending 6: [1 2 6] 0xc0003172c8
	//array now: [0 1 2 6 4 5]

	slice = append(slice, 7)
	fmt.Println("slice after appending 7:", slice, unsafe.SliceData(slice))
	fmt.Println("array now:", array)
	//slice after appending 7: [1 2 6 7] 0xc000317320
	//array now: [0 1 2 6 4 5]

扩容

容量增长表


Cap	[]int8	[]int32	[]int64
0	0	0	0
1			1
2		2	2
4		4	4
8	8	8	8
16	16	16	16
32	32	32	32
64	64	64	64
128	128	128	128
256	256	256	256
512	512	512	512
848			848
864		864	
896	896		
1280			1280
1344		1344	
1408	1408		
1792			1792
2048	2048	2048	
2560			2560
3072	3072	3072	3072
3408			3408
4096	4096	4096	4096
5120			5120
5376	5376		
5440		5440	
6912	6912		
7168		7168	7168
9216			9216
9472	9472		
10240		10240	10240
12288	12288		12288

当切片容量达到 512 时,扩容速度变缓 oldCap + (oldCap + 3*256) / 4

Preference

https://victoriametrics.com/blog/go-slice/ https://go.dev/src/runtime/slice.go https://cloud.tencent.com/edu/learning/course-2412-38275

Map

map 是 Go 内置的哈希表(键值对)数据结构,通过 key 快速定位 value,读写复杂度接近 O(1)。它是 Web3 开发中最常用的数据结构之一,例如用 map[string]string 维护「代币符号 → 合约地址」的映射。

基本特性

  • map 是无序的,每次遍历获取的顺序都不固定,且不能通过 index 获取,只能通过 key 获取
  • map 是一种引用类型,复制出的值的改变会影响本来的数据
  • map 的值可以直接通过 map["key"] = new value 修改
  • map 不是线程安全的,在多个 go-routine 存取时,必须使用 mutex lock 机制(或使用 sync.Map)
  • 可以通过 delete 关键字删除 map 数据 delete(map, key)
  • 初始化 map 必须分配内存空间,用 make 或者直接赋值分配;对 nil map 写入会 panic

定义与初始化

// make 初始化,指定预估容量可以减少扩容次数
contracts := make(map[string]string, 10)

// 字面量初始化
colors := map[string]int{
	"red":   1,
	"green": 2,
}

map[string]any:值可以是任意类型

前面两种写法的 value 类型是固定的(string 或 int)。如果希望同一个 map 里能放不同类型的 value,可以把 value 类型声明为 any:

// any 表示「任意类型」,value 不再局限于 string 或 int
params := map[string]any{
	"token":    "abc123", // string
	"protocol": 3,        // int
	"verbose":  true,     // bool
	"tags":     []string{"a", "b"},
}

any 是 interface{} 的别名(Go 1.18 引入),两者完全等价。它表示「这里可以放任何类型的值」,因此一个 map 里可以同时存在字符串、整数、布尔、切片等不同 value。

典型用法:构造请求参数

map[string]any 最常见的用途是拼装要序列化的请求参数——因为不同字段的类型天然不同,用结构体逐个定义反而繁琐。下面这段来自 WebSocket 连接握手(详见 WebSocket):

connectParams, _ := json.Marshal(map[string]any{
	"token":    resolveGatewayToken(),        // string
	"protocol": protocol.ProtocolVersion,     // int
})

json.Marshal 把 map 序列化成 JSON 字节:{"protocol":3,"token":"abc123"}。注意 map 无序,JSON 字段顺序与书写顺序无关,但解析方按 key 取值,不受影响。

取值需要类型断言

因为 any 抹掉了具体类型,取出来的值是 any,不能直接当 string 或 int 用,必须做类型断言还原:

v := params["protocol"]
// num := v + 1        // 编译错误:invalid operation

num, ok := v.(int)     // 类型断言,ok 为 false 表示实际类型不是 int
if ok {
	fmt.Println(num + 1) // 4
}

// 不确定类型时用 switch 分支处理
switch val := v.(type) {
case string:
	fmt.Println("string:", val)
case int:
	fmt.Println("int:", val)
default:
	fmt.Println("unknown type")
}

什么时候用 any

  • 适合:构造 JSON 请求参数、透传配置、临时聚合异构数据——这些场景类型本来就不固定
  • 不适合:业务核心数据结构。会丢失编译期类型检查,写错字段名或类型要到运行时才发现;能用结构体(或泛型,见 func)时优先用它们

完整示例

下面的示例覆盖了 map 的常见操作:初始化、写入、读取(comma-ok 断言)、遍历、删除,以及结构体作为 value 的用法:

package main

import "fmt"

type Limit struct {
	LimitationForOnce int64
	Whitelist         [2]string
}

func main() {
	// 1. make 初始化
	contracts := make(map[string]string)
	contracts["USDT"] = "0xdAC17F958D2ee523a2206206994597C13D831ec7"
	contracts["WETH"] = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"

	// 2. 读取:comma-ok 断言,key 不存在时 ok 为 false
	contract, ok := contracts["key"]
	if !ok {
		fmt.Println("key 不存在")
		_ = contract
	}

	// 3. 遍历(顺序不固定)
	for symbol, addr := range contracts {
		fmt.Printf("%s => %s\n", symbol, addr)
	}

	// 4. 删除
	delete(contracts, "WETH")
	fmt.Println(len(contracts)) // 1

	// 5. 结构体作为 value
	var LimitsFromSymbol = map[string]Limit{
		"symbol": {
			300,
			[2]string{
				"wl1",
				"wl2",
			},
		},
	}
	fmt.Println(LimitsFromSymbol["symbol"].LimitationForOnce) // 300
}

引用类型特性

map 赋值给新变量后,两者指向同一份底层数据,一方的修改对另一方可见:

a := map[string]int{"x": 1}
b := a
b["x"] = 100
fmt.Println(a["x"]) // 100,a 也被修改了

并发安全

多个 goroutine 同时读写同一个 map 会触发 fatal error: concurrent map read and map write(这种错误无法被 recover 捕获)。两种解决方案:

// 方案1:sync.RWMutex 读写锁
var (
	mu sync.RWMutex
	m  = make(map[string]int)
)

func read(key string) int {
	mu.RLock()
	defer mu.RUnlock()
	return m[key]
}

func write(key string, v int) {
	mu.Lock()
	defer mu.Unlock()
	m[key] = v
}
// 方案2:sync.Map,适合读多写少的场景
var sm sync.Map
sm.Store("USDT", 1)
v, ok := sm.Load("USDT") // v=1, ok=true
sm.Delete("USDT")

sort map struct

为什么要有它

Go 的 map 本身没有顺序,而且每次遍历的顺序都是随机的——你没法「对 map 排序」。当业务需要「按某个 struct 字段把 map 里的值排好序再返回」时,只能先把值取出来放进切片,再对切片排序。

sort map struct 把这套动作封装成两个泛型函数:

  • MapStrcutSort:把 map[KEY]VALUE 的值抽成 []VALUE,再按指定字段排序后返回。
  • SliceStrcutSort:直接对 []VALUE 按 struct 的某个字段排序(借助 reflect 取字段,支持 int / float / string)。

代码

package helper

import (
	"reflect"
	"sort"
)

// MapStrcutSort 对map进行排序,值为struct类型
func MapStrcutSort[KEY comparable, VALUE any](m map[KEY]VALUE, fieldName string) []VALUE {
	var ret = make([]VALUE, 0)
	for _, d := range m {
		ret = append(ret, d)
	}
	SliceStrcutSort(ret, fieldName)
	return ret
}

// SliceStrcutSort 对slice进行排序,值为struct类型
func SliceStrcutSort[VALUE any](s []VALUE, fieldName string) {
	defer func() {
		if r := recover(); r != nil {
		}
	}()
	sliceValue := reflect.ValueOf(s)
	less := func(i, j int) bool {
		v1 := reflect.Indirect(sliceValue.Index(i))
		v2 := reflect.Indirect(sliceValue.Index(j))
		if v1.Kind() != reflect.Struct || v2.Kind() != reflect.Struct {
			return false
		}
		valueI := v1.FieldByName(fieldName)
		valueJ := v2.FieldByName(fieldName)
		switch valueI.Kind() {
		case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
			return valueI.Int() < valueJ.Int()
		case reflect.Float32, reflect.Float64:
			return valueI.Float() < valueJ.Float()
		case reflect.String:
			return valueI.String() < valueJ.String()
		default:
			return false
		}
	}
	sort.Slice(s, less)
}

要点

  • map 不可排序,只能转切片:MapStrcutSort 先 for range 把值摊平到 []VALUE,再交给 SliceStrcutSort 排序,返回的已经是「排好序的切片」,原 map 不会被改动(也无从改动)。
  • 用 reflect 按字段名取值:FieldByName(fieldName) 按名字拿字段,所以传入的 fieldName 必须是导出字段(首字母大写);非导出字段 reflect 取不到,会走 default 分支当成相等处理。
  • 支持的类型:int 系列、float 系列、string。其他类型(bool、嵌套 struct、time.Time 等)在 switch 里落到 default,less 返回 false——也就是被当成「相等」,排序对这些字段实际上不生效。
  • reflect.Indirect 兼容指针元素:如果切片里放的是 *Struct,Indirect 会解引用到 struct;放的是值类型则原样返回,两种都能排。
  • panic 被静默吞掉:defer recover() 保证字段名写错、或切片为空等情况下不崩,但代价是「排序失败也不会报错」——排查时需注意:万一没排上序,大概率是 fieldName 拼错或字段类型不支持。
  • 两个陷阱提醒:
    • sort.Slice 不是稳定排序;若多个元素目标字段相等,它们的相对顺序不保证。需要确定性再排序就用 sort.SliceStable。
    • 只对单个字段排序,不支持 A.B 这样的嵌套路径。

这两个函数的名字(MapStrcutSort / SliceStrcutSort)按原 helper 包命名保留;如想更规范可改名 MapStructSort / SliceStructSort。泛型约束 KEY comparable 是 map key 的硬性要求,VALUE any 则可接受任意结构体。

通道

Channel

channel 是 goroutine 之间通信的管道,「不要通过共享内存来通信,而要通过通信来共享内存」正是 Go 并发的核心理念。发送方 ch <- v,接收方 v := <-ch,同一时间只有一个 goroutine 能访问数据,天然避免了数据竞争。

  • channel 需要 make 初始化分配内存
    • make 初始化内存时可以设定缓存空间,在缓存空间被占用完之前都不会阻塞通道写入
      • channel := make(chan int, len)
      • 有缓存的可以在协程外将数据写入缓存区
      • 缓存区数据写满后,再次写入会陷入阻塞,但是程序编译不会报错
    • make 初始化可以不设置缓存空间,表示通道处理同步消息:在通道数据被读取前都会阻塞通道写入
      • channel := make(chan int)
      • 无缓存通道数据的读写都必须在 go 协程中处理
package main

import (
	"fmt"
	"sync"
)

var (
	wg sync.WaitGroup
	ch = make(chan int, 11)
)

func main() {
	wg.Add(2)
	go write()
	go read()
	wg.Wait()
}

func write() {
	defer wg.Done()
	for i := 0; i < 10; i++ {
		ch <- i
	}
	defer close(ch) // 在写入完成后关闭通道
}

func read() {
	defer wg.Done()
	for a := range ch { // 使用 range 语法读取通道
		fmt.Println("a: ", a)
	}
	fmt.Println("close")
}

无缓冲通道与 select

无缓冲通道在发送和接收之间建立同步点:ch <- v 会阻塞到有接收方取走数据为止,适合两个 goroutine 间的「握手」。

select 语句可以同时等待多个通道操作,哪个就绪执行哪个(多个同时就绪时随机选择),配合 time.After 可以实现超时控制:

package main

import (
	"fmt"
	"time"
)

func main() {
	ch := make(chan string) // 无缓冲通道

	go func() {
		time.Sleep(1 * time.Second)
		ch <- "block synced"
	}()

	select {
	case msg := <-ch:
		fmt.Println("收到:", msg)
	case <-time.After(2 * time.Second):
		fmt.Println("超时退出")
	}
}

输出:

收到: block synced

如果 goroutine 中 sleep 的时间超过 2 秒,则会走到超时分支打印「超时退出」。这是 Web3 监听链上事件时常用的模式——用 select + 超时避免 RPC 无响应导致程序永久阻塞。

Channel 数据读取

读数据

  • 从空通道读取数据-->阻塞
  • 从 nil 通道读取数据-->阻塞
  • 从有数据通道读取数据-->返回数据
  • 从 关闭的通道读取数据-->可以读取缓存区的数据,缓存区为空后,再次读取会返回 默认值和false

写数据

  • 往 nil 通道写数据-->阻塞
  • 往关闭的通道写数据-->panic 异常
  • 往无缓存通道写数据,在数据被读取前会陷入阻塞
  • 往缓存区通道写入数据,缓存区写满后会陷入阻塞

Func

函数是 Go 代码复用的基本单元。本节介绍三种常用的函数使用模式:不定长参数、指针传参和函数作为值传递,这些都是 Web3 开发中封装工具函数的基础。

不定长参数与空接口

golang 函数中允许传递不定长的数据,或者传递 interface{} 空接口,用来接收任意类型的数据。

  • args ...interface{} 表示任意数量、任意类型的参数,args 在函数体内是一个切片
  • 遍历时可以用类型断言 v.(type) 区分具体类型(需在 switch 语句中使用)
package main

import (
	"fmt"
	"math/big"
)

func main() {
	type Tree struct {
		leaves int
		name   string
	}
	myFunc(1, big.NewInt(23), Tree{99, "trace"}, "hello world", []byte("hello"))
}
func myFunc(args ...interface{}) {
	for _, v := range args {
		fmt.Println(v)
	}
}

输出:

1
23
{99 trace}
hello world
[104 101 108 108 111]

指针传参

函数之间传递指针内存地址可以共同操作相同的数据,避免 copy data 带来的拷贝负担。

Go 中所有传参都是值传递,指针传递的其实是「指针的值」——副本指向同一片内存,因此函数内对 *a 的修改对外部可见。大结构体传参时传指针可以显著减少拷贝开销:

package main

import (
	"fmt"
)

func main() {
	a := 1
	b := add(&a)
	fmt.Println(a) // 2,a 被 add 函数修改
	fmt.Println(b) // 2
}

func add(a *int) (addone int) {
	*a = *a + 1
	addone = *a
	return
}

函数作为值传递

函数也可以作为参数传参,处理特定的业务逻辑。这在 Go 中称为「一等函数」——函数可以像普通值一样赋值给变量、存入切片、作为参数和返回值,是实现回调、过滤器和中间件的基础:

package main

import (
	"fmt"
)

// 定义函数类型,签名必须是 func(int) bool
type testInt func(int) bool

func check(a int) bool {
	switch a % 2 {
	case 0:
		return true
	case 1:
		return false
	}
	return false
}

// filter 接收一个函数 f 作为过滤条件
func filter(a []int, f testInt) (b []int) {
	for _, v := range a {
		if f(v) {
			b = append(b, v)
		}
	}
	return
}

func main() {
	a := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
	odd := filter(a, check)
	// check 对偶数返回 true,因此过滤出的实际是偶数
	fmt.Println(odd) // [0 2 4 6 8 10]
}

泛型 (Generics)

泛型让你在定义函数、类型时预留「类型参数」,从而写出与具体类型解耦、又能保持类型安全的代码。

[T any] 表示声明一个类型参数 T,any 约束 T 可以是任意类型(语义等价于 interface{},但泛型保留了编译期的真实类型)。使用时必须把 T 替换为具体类型,例如 SelectOption[string]、SelectOption[int]。这样既避免了 interface{} 的装箱开销和类型断言,又能在编译期校验类型。

// SelectOption 表示下拉框中的单条选项,Label 展示文案,Value 绑定实际值
type SelectOption[T any] struct {
	Label string
	Value T
}

func main() {
	// 用具体类型 string 实例化泛型类型
	options := make([]SelectOption[string], 0, 3)
	options = append(options, SelectOption[string]{
		Label: "Ethereum",
		Value: "0x1",
	})
	options = append(options, SelectOption[string]{
		Label: "Optimism",
		Value: "0x10",
	})

	for _, opt := range options {
		fmt.Printf("label=%s value=%s\n", opt.Label, opt.Value)
	}
}

输出:

label=Ethereum value=0x1
label=Optimism value=0x10

同理,泛型函数也能复用逻辑而无需为每种类型各写一份,例如:

// 泛型函数:返回切片中的第一个元素,适用于任意类型
func first[T any](s []T) (T, bool) {
	var zero T
	if len(s) == 0 {
		return zero, false
	}
	return s[0], true
}

func main() {
	v, ok := first(options)
	fmt.Println(v.Label, ok) // Ethereum true
}

多类型实例化

同一个泛型类型可以在不同地方分别用不同的具体类型实例化。SelectOption[string] 和 SelectOption[int] 在编译期是两个完全不同的类型,彼此独立、互不影响:

func main() {
	// 地方一:string 版选项(链 ID 用字符串标识)
	chainOpts := make([]SelectOption[string], 0, 2)
	chainOpts = append(chainOpts, SelectOption[string]{Label: "Ethereum", Value: "0x1"})
	chainOpts = append(chainOpts, SelectOption[string]{Label: "Optimism", Value: "0x10"})

	// 地方二:int 版选项(区块高度用整数)
	heightOpts := make([]SelectOption[int], 0, 2)
	heightOpts = append(heightOpts, SelectOption[int]{Label: "Block 1", Value: 1})
	heightOpts = append(heightOpts, SelectOption[int]{Label: "Block 100", Value: 100})

	fmt.Println(chainOpts[0].Value)  // 0x1
	fmt.Println(heightOpts[1].Value) // 100
}

需要注意的约束:

  1. 同一切片内类型必须一致。[]SelectOption[string] 只能容纳 SelectOption[string],混入 SelectOption[int] 会直接编译报错——两者虽然都由同一个模板生成,但类型系统认为它们无关。
  2. 各自独立。上面 chainOpts 和 heightOpts 可以共存于同一函数、同一包,互不干扰。
  3. 如果确实需要「一个切片装多种类型的值」,应改用 interface{} / any,但这样就失去了泛型带来的编译期类型安全,一般不推荐混用。

Defer

defer 用于注册延迟调用:函数返回前、或 panic 发生时,被 defer 的语句会按照「后进先出」的顺序执行。它常用于资源释放(关闭文件、解锁 mutex、恢复 panic)。

执行时机的关键规则:return xxx 并不是原子操作,而是分两步——先把返回值赋值,再执行 defer,最后真正返回。因此 defer 中修改命名返回值会影响最终返回结果,而修改普通局部变量则不会。

  1. stack

包内执行过程中,defer 存储在栈空间 2. return

defer 执行在 return 后,先执行 return 的内容,在按照栈内存储顺序从栈顶向下依次执行 defer 语句 3. panic

遇到 panic 之后,按照出栈的方式执行 defer 函数,直到被 recover() 函数捕获

  • recover 函数捕获异常
  • 栈内的 defer 仍然会执行,直到空栈

Examples-Panic-Without-Recover

package main

import (
    "fmt"
)

func main() {
    defer_call()

    fmt.Println("main 正常结束")
}

func defer_call() {
    defer func() { fmt.Println("defer: panic 之前1") }()
    defer func() { fmt.Println("defer: panic 之前2") }()
    panic("异常内容")  //触发defer出栈
	defer func() { fmt.Println("defer: panic 之后,永远执行不到") }()
}
/**
defer: panic 之前2
defer: panic 之前1
panic: 异常内容
//... 异常堆栈信息**/

Examples-Panic-With-Recover

package main

import "fmt"

func main() {
	defer_call()

	fmt.Println("main 正常结束")
}

func defer_call() {
	defer func() { fmt.Println("defer: before recover()") }()

	defer func() {
		if err := recover(); err != nil {
			fmt.Println(err)
		}
		fmt.Println("defer: panic 之前1, 捕获异常")
	}()

	defer func() { fmt.Println("defer: panic 之前2, 不捕获") }()

	panic("异常内容") //触发defer出栈

	defer func() { fmt.Println("defer: panic 之后, 永远执行不到") }()
}

/**
defer: panic 之前2, 不捕获
异常内容
defer: panic 之前1, 捕获异常
defer: before recover()
main 正常结束**/  //recover()defer func()之前的defer也会执行,在输出Panic内容后,会执行完所有的defer栈

Examples-Multi-Panic-With-Recover

panic仅有最后一个可以被revover捕获

package main

import (
    "fmt"
)

func main()  {

    defer func() {
       if err := recover(); err != nil{
           fmt.Println(err)
       }else {
           fmt.Println("fatal")
       }
    }()

    defer func() {
        panic("defer panic")
    }()

    panic("panic")
}
/**
defer panic**/

Examples-Defer-With-Nested-Func

defer 下的函数包含子函数的话:

  • 在入栈函数的时候,需要获取该函数所有的参数
  • 因此如果函数中嵌套了子函数的话,在入栈时会先执行这个子函数
package main

import "fmt"

func function(index int, value int) int {
    fmt.Println(index)
    return index
}

func main() {
    defer function(1, function(3, 0))
    defer function(2, function(4, 0))
}
/**
defer压栈function1,压栈函数地址、形参1、形参2(调用function3) --> 打印3
defer压栈function2,压栈函数地址、形参1、形参2(调用function4) --> 打印4
defer出栈function2, 调用function2 --> 打印2
defer出栈function1, 调用function1--> 打印1**/
package main

import "fmt"

func DeferFunc1(i int) (t int) {
	t = i //赋值t=1
	defer func() {
		t += 3 //defer 执行在return之后,返回 t = 4
	}()
	return t //t=1
}

func DeferFunc2(i int) int {
	t := i //t =1
	defer func() {
		t += 3 //局部变量
	}()
	return t //将t赋值给返回的值 返回t=1
}

func DeferFunc3(i int) (t int) {
	defer func() {
		t += i // 返回 t + 1 = 3
	}()
	return 2 //t = 2
}

func DeferFunc4() (t int) {
	defer func(i int) {
		fmt.Println(i) //i = 0
		fmt.Println(t) // t =2
	}(t)
	t = 1
	return 2 //t = 2
}

func main() {
	fmt.Println(DeferFunc1(1))
	fmt.Println(DeferFunc2(1))
	fmt.Println(DeferFunc3(1))
	DeferFunc4()
}

输出与解析:

4
1
3
0
2
  1. DeferFunc1 返回 4:命名返回值 t,return t 先把 t=1 赋给返回值,defer 中 t += 3 修改的就是返回值本身,因此最终返回 4。
  2. DeferFunc2 返回 1:匿名返回值,return t 时把局部变量 t 的值 1 拷贝给返回值,之后 defer 修改的只是局部变量 t,不影响已拷贝出去的返回值。
  3. DeferFunc3 返回 3:命名返回值 t,return 2 把 2 赋给 t,defer 中 t += i(i=1)作用于返回值,最终 3。
  4. DeferFunc4 打印 0 和 2:defer 语句的参数在 defer 注册时求值,此时 t 还是零值 0,所以匿名函数里 i=0;而 t 是闭包引用,defer 真正执行时 t 已被 return 2 赋为 2,故打印 2。

safego

为什么要有它

实际业务里经常要「并行做几件互不依赖的事」——比如初始化时同时连数据库、拉配置、预热缓存。最朴素的做法是开一堆 goroutine,自己用 sync.WaitGroup + 一个 error channel 收集错误,但样板代码又长又容易漏(忘了 Wait、忘了 close channel、忘了把错误传出来)。

safego 用标准扩展库 golang.org/x/sync/errgroup 把这套模式封装成两个函数:传入一组 WorkFunc,并发跑,等全部结束,返回第一个 error 或 nil。

代码

import (
	"context"

	"golang.org/x/sync/errgroup"
)

// WorkFunc is simple work func
type WorkFunc func() error

// WorkFuncWithCtx is simple work func
type WorkFuncWithCtx func(ctx context.Context) error

// MultiRun run all WorkFunc, it will return first err or nil.
// caution: It waits all WorkFunc done, err won't interrupt or notice other WorkFunc when it happened.
func MultiRun(fs ...WorkFunc) error {
	if len(fs) == 0 {
		return nil
	}
	eg := &errgroup.Group{}
	for i := range fs {
		if fs[i] == nil {
			continue
		}
		eg.Go(fs[i])
	}
	return eg.Wait()
}

// MultiRunWithCtx is mostly like MultiRun, but with ctx notify.
func MultiRunWithCtx(fs ...WorkFuncWithCtx) error {
	if len(fs) == 0 {
		return nil
	}
	eg, ctx := errgroup.WithContext(context.Background())
	for i := range fs {
		if fs[i] == nil {
			continue
		}
		j := i
		eg.Go(func() error {
			return fs[j](ctx)
		})
	}
	return eg.Wait()
}

MultiRun:并行跑,互不打断

MultiRun 用 errgroup.Group{}(零值即可用)把每个 WorkFunc 丢进 eg.Go 并发执行:

  • eg.Go(fs[i]) 直接把函数当参数传进去——WorkFunc 本身就是 func() error,和 eg.Go 要的签名一致,所以不需要再包一层闭包,也就没有「循环变量捕获」的坑。
  • 遇到 nil 的函数直接 continue 跳过,调用方不用怕传空。
  • eg.Wait() 会阻塞到所有函数都跑完,然后返回第一个非 nil 的 error(其余 error 被丢弃)。

关键点(对应注释里的 caution):MultiRun 没有 context,某个函数出错时,errgroup 既不会取消任何东西,也不会通知其他函数。其他函数会一直跑到自己结束。所以如果你的 WorkFunc 里在跑长任务,它不会因为「兄弟任务失败了」而提前停下——它根本不知道。

MultiRunWithCtx:带 ctx 通知

MultiRunWithCtx 行为大体一样,但多了一层 context:

  • errgroup.WithContext 返回一个派生的 ctx,以及绑定到它身上的 errgroup.Group。
  • 只要任意一个 WorkFunc 返回非 nil error(或外部取消这个 ctx),派生 ctx 会被自动取消。其他函数手里拿到的 ctx 就会变成 Done,它们可以主动 select ctx.Done() 退出来「提前止损」。
  • 因为 WorkFuncWithCtx 需要 ctx 入参,eg.Go 要的却是无参 func() error,所以这里必须包一层闭包 func() error { return fs[j](ctx) }。注意 j := i 这个捕获——把下标拷到局部变量再闭包,避免所有闭包共享同一个循环变量 i。

但要清醒:WithContext 只是「通知」,不会物理抢占正在跑的 goroutine。errgroup 的 Wait 仍然会等所有函数返回。能不能早停,完全看你的函数有没有去监听 ctx.Done()。

两个版本怎么选

场景用哪个
几个独立任务并行,谁失败都无所谓,等全部跑完拿第一个错MultiRun
任务里有长耗时的循环 / 网络调用,希望一个失败就让其他任务尽快退出MultiRunWithCtx,并且在函数体里监听 ctx.Done()
任务之间需要共享数据或顺序依赖都不合适,回到 channel / 普通 goroutine 自己编排

两者的共同前提:传入的函数本身应当是并发安全的,它们共享的变量要么各自独立,要么用锁 / channel 保护。safego 只负责「并发调度 + 错误聚合」,不替你管数据竞争。

用法示例

// 真实项目中 safego 一般放在 helper 包,调用时带 helper. 前缀(与本章前面的定义是同一套 API)。
// 按位 flag 决定并行填充哪些用户信息:需要的才加进切片,最后一次性并发跑,任一失败返回第一个 error。
func covertUserInfo(flag int, userIds []int64, titleId int64, respData *Resp) error {
	fs := make([]helper.WorkFunc, 0)
	if flag&CovertUserInfoFlagUser != 0 {
		fs = append(fs, func() error { return fillUserBasic(userIds, titleId, respData) })
	}
	if flag&CovertUserInfoFlagWallet != 0 {
		fs = append(fs, func() error { return fillUserWallet(userIds, respData) })
	}
	if flag&CovertUserInfoFlagLevel != 0 {
		fs = append(fs, func() error { return fillUserLevel(userIds, respData) })
	}
	if flag&CovertUserInfoFlagConnect != 0 {
		fs = append(fs, func() error { return fillUserConnect(userIds, respData) })
	}
	if flag&CovertUserInfoFlagSource != 0 {
		fs = append(fs, func() error { return fillUserSource(respData) })
	}
	if flag&CovertUserInfoFlagOik != 0 {
		fs = append(fs, func() error { return fillUserOik(userIds, respData) })
	}
	return helper.MultiRun(fs...)
}

// 带 ctx 的版本:某个任务失败时,longTask 能通过 ctx 提前退出
func initAll() error {
	return helper.MultiRunWithCtx(
		func(ctx context.Context) error { return longTask(ctx) },
		func(ctx context.Context) error { return fetchRemote(ctx) },
	)
}

func longTask(ctx context.Context) error {
	for {
		select {
		case <-ctx.Done(): // 兄弟任务失败 → ctx 取消 → 这里立刻退出
			return ctx.Err()
		default:
			// 干活……
		}
	}
}

安装依赖:go get golang.org/x/sync/errgroup。更多 errgroup 细节(如 SetLimit 限制并发数)见官方文档。

GMP

一、Golang“调度器”的由来?

(1) 单进程时代不需要调度器

一切的软件都是跑在操作系统上,真正用来干活(计算)的是 CPU。

  • 早期的操作系统每个程序就是一个进程,直到一个程序运行完,才能进行下一个进程,就是“单进程时代”
  • 一切的程序只能串行发生。

早期的单进程操作系统,面临2个问题:

1.单一的执行流程,计算机只能依次处理任务

2.单次任务进程阻塞所带来的CPU时间浪费

那么能不能有多个进程来宏观一起来执行多个任务呢?

(2)多进程/线程时代有了调度器需求

多进程并发

  • 在多进程/多线程的操作系统中,因为一个进程阻塞,cpu 可以立刻切换到其他进程中去执行
  • 调度 cpu 的算法可以保证在运行的进程都可以被分配到 cpu 的运行时间片
  • 从宏观来看,似乎多个进程是在同时被运行

但是对于 Linux 操作系统来讲,cpu 对进程的态度和线程的态度是一样的 但新的问题就又出现了:

  • 调度的高消耗 CPU:
    • 进程拥有太多的资源,进程的创建、切换、销毁,都会占用很长的时间,但如果进程过多,CPU 有很大的一部分都被用来调度进程
    • CPU 调度切换的是进程和线程,实际上多线程开发要考虑同步竞争等问题,如锁、竞争冲突等
  • 高内存占用: 为每个任务都创建一个线程是不现实的,因为会消耗大量的内存(进程虚拟内存会占用4GB[32位操作系统], 而线程也要大约4MB)

怎么才能提高 CPU 的利用率呢?

(3)协程来提高CPU利用率

其实一个线程分为“内核态“线程和”用户态“线程。

一个“用户态线程”必须要绑定一个“内核态线程”,但是 CPU 并不知道有“用户态线程”的存在,它只知道它运行的是一个“内核态线程”(Linux的PCB进程控制块)。

这样,内核线程依然叫“线程(thread)”,用户线程叫“协程(co-routine)".

_既然一个协程(co-routine)可以绑定一个线程(thread),那么能不能多个协程(co-routine)绑定一个或者多个线程(thread)上呢?_

N:1关系

N个用户态协程绑定1个内核态线程

  • 优点
    • 协程在用户态线程即完成切换,不会陷入到内核态,这种切换非常的轻量快速
  • 缺点
    • 1个进程的所有协程都绑定在1个线程上
    • 某个程序用不了硬件的多核加速能力
    • 一旦某协程阻塞,造成线程阻塞,本进程的其他协程都无法执行了,根本就没有并发的能力了。

1:1 关系

1个协程绑定1个线程

  • 优点
    • 协程的调度都由 CPU 完成了,不存在N:1中阻塞的问题
  • 缺点:
    • 协程的创建、删除和切换的代价都由 CPU 完成,和进程线程调度一样,调度的高消耗 CPU

M:N关系

M个协程绑定N个线程,是N:1和1:1类型的结合,克服了以上2种模型的缺点,但实现起来最为复杂

协程跟线程是有区别的,线程由CPU调度是抢占式的,协程由用户态调度是协作式的,一个协程让出CPU后,才执行下一个协程

(4)Go语言的协程goroutine

Go为了提供更容易使用的并发方法,使用了 goroutine 和 channel。

  • 协程被称为 goroutine
    • 一个 goroutine 只占几 KB,并且这几 KB 就足够 goroutine 运行完,goroutine 的内存栈是可伸缩的,如果需要更多内容,runtime会自动为 goroutine 分配。
    • 支持在有限的内存空间内并发大量 goroutine,
  • goroutine 让一组可复用的函数运行在一组线程之上,即使有协程阻塞,该线程的其他协程也可以被runtime调度,转移到其他可运行的线程上

Goroutine 特点:

  • 占用内存更小(几kb)
  • 调度更灵活(runtime 调度)

(5)被废弃的goroutine调度器

最关键的一点就是调度协程的调度器的实现了。

G来表示Goroutine,用M来表示线程,那么我们也会用这种表达的对应关系。

Go之前被废弃的调度器是如何运作的?

M(线程) 想要执行/放回G(协程)都必须访问全局 G 队列,并且 M 有多个,即多线程访问同一资源需要加锁进行保证互斥/同步,所以全局 G 队列是有互斥锁进行保护的

  1. 创建、销毁、调度 G 都需要每个 M 获取锁,这就形成了激烈的锁竞争。
  2. M 转移 G 会造成延迟和额外的系统负载。比如当 G 中包含创建新协程的时候,M 创建了 G’,为了继续执行 G,需要把 G’ 交给 M’ 执行,也造成了很差的局部性,因为 G’ 和 G 是相关的,最好放在 M 上执行,而不是其他 M'。
  3. 系统调用(CPU 在 M 之间的切换)导致频繁的线程阻塞和取消阻塞操作增加了系统开销。

二、Goroutine调度器的GMP模型的设计思想

面对之前调度器的问题,Go 设计了新的调度器。

在新调度器中,除了M(thread)和G(goroutine),又引进了P(Processor)

** Processor,它包含了运行 goroutine 的资源**,如果线程 M 想运行 goroutine ,必须先获取 P,P 中还包含了可运行的 G 队列。

(1)GMP模型

在 Go 中,线程 M 是运行 goroutine 的实体,调度器的功能是把用户态中可运行的 goroutine 分配到工作线程上。

  1. 全局队列(Global Queue):存放等待运行的G(goroutine)
  2. P的本地队列:同全局队列类似,存放的也是等待运行的 G,存的数量有限,不超过256个。新建G'时,G'优先加入到 P 的本地队列,如果队列满了,则会把本地队列中一半的 G 移动到全局队列
  3. P列表:所有的P都在程序启动时创建,并保存在数组中,最多有GOMAXPROCS(可配置)个。
  4. M:线程想运行任务就得获取P,从P的本地队列获取G,P队列为空时,M也会尝试从全局队列取一批G放到P的本地队列,或从其他P的本地队列拿出一半放到自己P的本地队列。M运行G,G执行之后,M会从P获取下一个G,不断重复下去

Goroutine 调度器和OS调度器是通过线程M结合起来的,每个M都代表了1个内核线程,OS调度器负责把内核线程分配到CPU的核上执行。

有关P和M的个数问题

  1. P 的数量:
  • 由启动时环境变量$GOMAXPROCS或者是由runtime的方法GOMAXPROCS()决定。这意味着在程序执行的任意时刻都只有$GOMAXPROCS个 goroutine 在同时运行。
  1. M线程的数量:
  • go 程序启动时,会设置 M 的最大数量,默认 10000.但是内核很难支持这么多的线程数,所以这个限制可以忽略。
  • runtime/debug 中的 SetMaxThreads 函数,设置 M 的最大数量
  • 一个 M 阻塞了,会创建新的 M

M 与 P 的数量没有绝对关系,一个 M 线程阻塞,P 就会去创建或者切换另一个 M,所以,即使 P 的默认数量是1,也有可能会创建很多个 M 出来。

P和M何时会被创建

  1. P 何时创建:在确定了 P 的最大数量 n 后,运行时系统会根据这个数量创建 n个P
  2. M 何时创建:古国没有足够的 M 线程来关联 P 并运行其中的可运行的 G
  • 比如所有的 M 线程此时都阻塞住了,而 P 中还有很多就绪任务
    • 先去就会去寻找空闲的 M 执行协程任务
    • 如果没有空闲的,就会去创建新的 M

(2)调度器的设计策略

复用线程:避免频繁的创建、销毁线程,而是对线程的复用

  1. work stealing机制

当本线程无可运行的 G 时,尝试从全局队列或者其他线程绑定的 P 获取待执行的任务 G,而不是销毁线程

  1. hand off机制

当本线程 M 因为 G 进行系统调用阻塞时,线程 M 释放绑定的 P ,把 P 转移给其他空闲的线程执行

  1. 抢占
  • 在 goroutine 中要等待一个协程主动让出 CPU 才执行下一个协程
  • 一个 goroutine 最多占用 CPU 10ms,防止其他 goroutine 被阻塞饿死

(3) go func() 调度流程

  1. go 关键字来创建一个程序调用的 goroutine;
  2. 有两个存储 G 的队列,一个是局部调度器 P 的本地队列、一个是全局 G 队列。
  • 新创建的 G 会先保存在 P 的本地队列中
  • 如果 P 的本地队列已经满了就会保存在全局的队列中
  1. G 只能运行在 M 中,一个 M 必须持有一个 P,M与P是1:1 的关系。
  • M 会从 P 的本地队列弹出一个可执行状态的 G 来执行
  • 如果 P 的本地队列为空,就会从其他的 MP 组合获取一个可执行的 G 来执行或者从全局 G 队列获取任务
  1. 一个 M 调度 G 执行的过程是一个循环机制;
  • 当 M 执行某一个 G 时候如果发生了 syscall 或则其余阻塞操作引起的 M 阻塞
    • 如果当前 P 中有一些 G 在执行,runtime 会把这个线程 M 从 P 中摘除(detach),然后再创建一个新的操作系统的线程(如果有空闲的线程可用就复用空闲线程)来服务于这个 P;
  • 当 M 系统调用结束时候
    • G 会尝试获取空闲 P 任务,并放入到这个 P 的本地队列
    • 如果 G 获取不到空闲 P,那么这个线程 M 变成休眠状态,G 放回到全局队列

(4)调度器的生命周期

特殊的 M0和G0

  1. M0是启动程序后的编号为0的主线程,这个 M 对应的实例会在全局变量 runtime.m0,不需要在 heap 上分配,M0 负责执行初始化操作和启动第一个 G, 在之后 M0 就和其他的 M 一样
  2. G0是每次启动一个 M 都会第一个创建的 goroutine,G0 仅用于负责调度的 G,G0 不指向任何可执行的函数, 每个 M 都会有一个自己的 G0。在调度或系统调用时会使用 G0 的栈空间, 全局变量的 G0 是 M0 的 G0。
package main

import "fmt"

func main() {
    fmt.Println("Hello world")
}

分析:

  1. runtime 创建最初的线程 m0 和 goroutine g0,并把2者关联。
  2. 调度器初始化:初始化 m0、栈、垃圾回收,以及创建和初始化由 GOMAXPROCS 个 P 构成的 P 列表。
  3. 示例代码中的 main 函数是main.main
  • runtime中也有1个 main 函数——runtime.main
  • 代码经过编译后,runtime.main会调用main.main
  • 程序启动时会为runtime.main创建 goroutine
  • 然后把 main goroutine 加入到 P 的本地队列。
  1. 启动 m0,m0 已经绑定了 P,会从 P 的本地队列获取 G,获取到 main goroutine。
  2. G 拥有栈,M 根据 G 中的栈信息和调度信息设置运行环境
  3. M 运行 G
  4. G 退出,再次回到 M 获取可运行的 G
  • 重复直到main.main退出
    • runtime.main执行 Defer 和 Panic 处理,或调用runtime.exit退出程序。

(5)可视化GMP编程

有2种方式可以查看一个程序的 GMP 的数据。

方式1:go tool trace

trace 记录了运行时的信息,能提供可视化的 Web 页面。

package main

import (
    "os"
    "fmt"
    "runtime/trace"
)

func main() {

    //创建trace文件
    f, err := os.Create("trace.out")
    if err != nil {
        panic(err)
    }

    defer f.Close()

    //启动trace goroutine
    err = trace.Start(f)
    if err != nil {
        panic(err)
    }
    defer trace.Stop()

    //main
    fmt.Println("Hello World")
}

运行程序

$ go tool trace trace.out 
2020/02/23 10:44:11 Parsing trace...
2020/02/23 10:44:11 Splitting trace...
2020/02/23 10:44:11 Opening browser. Trace viewer is listening on http://127.0.0.1:33479

我们可以通过浏览器打开http://127.0.0.1:33479网址,点击view trace 能够看见可视化的调度流程。

G信息

点击 Goroutines 那一行可视化的数据条,我们会看到一些详细的信息。

一共有两个 G 在程序中,一个是特殊的 G0,是每个M必须有的一个初始化的 G

其中 G1 应该就是 main goroutine (执行 main 函数的协程),在一段时间内处于可运行和运行的状态。

M信息

点击 Threads 那一行可视化的数据条,我们会看到一些详细的信息。

一共有两个 M 在程序中,一个是特殊的 M0,用于初始化使用

P信息

G1 中调用了main.main,创建了trace goroutine g18。G1 运行在 P1 上,G18 运行在 P0 上。

这里有两个 P,我们知道,一个 P 必须绑定一个 M 才能调度 G。

G18 在 P0 上被运行的时候,确实在 Threads 多了一个 M 的数据,点击查看如下:

多了一个 M2 应该就是 P0 为了执行 G18 而动态创建的 M2.

方式2:Debug trace

package main

import (
    "fmt"
    "time"
)

func main() {
    for i := 0; i < 5; i++ {
        time.Sleep(time.Second)
        fmt.Println("Hello World")
    }
}

编译

$ go build trace2.go

通过Debug方式运行

$ GODEBUG=schedtrace=1000 ./trace2 
SCHED 0ms: gomaxprocs=2 idleprocs=0 threads=4 spinningthreads=1 idlethreads=1 runqueue=0 [0 0]
Hello World
SCHED 1003ms: gomaxprocs=2 idleprocs=2 threads=4 spinningthreads=0 idlethreads=2 runqueue=0 [0 0]
Hello World
SCHED 2014ms: gomaxprocs=2 idleprocs=2 threads=4 spinningthreads=0 idlethreads=2 runqueue=0 [0 0]
Hello World
SCHED 3015ms: gomaxprocs=2 idleprocs=2 threads=4 spinningthreads=0 idlethreads=2 runqueue=0 [0 0]
Hello World
SCHED 4023ms: gomaxprocs=2 idleprocs=2 threads=4 spinningthreads=0 idlethreads=2 runqueue=0 [0 0]
Hello World

  • SCHED:调试信息输出标志字符串,代表本行是 goroutine 调度器的输出;
  • 0ms:即从程序启动到输出这行日志的时间;
  • gomaxprocs: P的数量,本例有2个 P, 因为默认的 P 的属性是和 cpu 核心数量默认一致,当然也可以通过 GOMAXPROCS 来设置;
  • idleprocs: 处于idle状态的P的数量;通过 gomaxprocs 和 idleprocs 的差值,我们就可知道执行 go 代码的 P 的数量;
  • threads: os threads/M的数量,包含 scheduler 使用 m 数量,加上 runtime 自用的类似 sysmon 这样的 thread 的数量;
  • spinningthreads: 处于自旋状态的 os thread 数量;
  • idlethread: 处 idle 状态的 os thread 的数量;
  • runqueue=0: Scheduler 全局队列中 G 的数量;
  • [0 0]: 分别为2个 P 的 local queue 中的 G 的数量。

三、Go调度器调度场景过程全解析

(1)场景1

P 拥有代运行的协程任务 G1,M1 线程获取 P 后开始运行 G1

G1 使用go func()创建了 G2, G2 优先加入到 P1 的本地队列。

G1 运行完成后(函数:goexit),M 上运行的 goroutine 切换为 G0

G0 负责调度时协程的切换(函数:schedule)

从 P 的本地队列取 G2,从 G0 切换到 G2,并开始运行 G2 (函数:execute),实现了线程 M1 的复用。

(2)场景2

假设每个 P 的本地队列只能存 4个G。

G2 要创建了6个G,前4个G(G3, G4, G5,G6)已经加入 p1 的本地队列,p1 本地队列满了。

G2 在创建 G7 的时候,发现 P1 的本地队列已满,需要执行负载均衡

把 P1 中本地队列中前一半的 G,还有新创建 G 转移到全局队列

(实现中并不一定是新的 G,如果 G 是 G2 之后就执行的,会被保存在本地队列,

利用某个老的 G 替换新 G 加入全局队列)

这些 G 被转移到全局队列时,会被打乱顺序。所以 G3,G4,G7 被转移到全局队列。

G2 创建 G8 时,P1 的本地队列未满,所以 G8 会被加入到 P1 的本地队列。

G8 加入到 P1 点本地队列的原因:

P1 此时在与 M1 绑定,而 G2 此时是 M1 在执行

所以 G2 创建的新的 G 会优先放置到自己的M绑定的 P 上

(3)场景3

在创建 G 时,运行的 G 会尝试唤醒其他空闲的 P 和 M 组合去执行。

假定 G2 唤醒了 M2,M2 绑定了 P2,并运行 G0

但 P2 本地队列没有 G,M2 此时为自旋线程(没有 G 但为运行状态的线程,不断寻找 G)

M2 首先尝试从全局队列(简称“GQ”)取一批 G 放到 P2 的本地队列(函数:findrunnable())

M2 从全局队列取的 G 数量符合下面的公式:

n = min(len(GQ)/GOMAXPROCS + 1, len(GQ)/2)

至少从全局队列取 1个G,但每次不要从全局队列移动太多的 G 到 P 本地队列,给其他 P 留点

这是从全局队列到 P 本地队列的负载均衡。

(4)场景4

假设 G2 一直在 M1 上运行

经过2轮后,M2 已经把 G7、G4 从全局队列获取到了 P2 的本地队列并完成运行

全局队列和 P2 的本地队列都空了,如场景8图的左半部分。

**全局队列已经没有 G,那m就要执行 work stealing(借取)

从其他有 G 的 P 哪里借取一半 G 过来,放到自己的 P 本地队列

P2 从 P1 的本地队列尾部取一半的 G,本例中一半则只有 1个G8,放到 P2 的本地队列并执行

G1 本地队列 G5、G6 已经被其他 M 运行完成

当前 M1 和 M2 分别在运行 G2 和 G8

M3 和 M4 没有 goroutine 可以运行,M3 和 M4 处于自旋状态,它们不断寻找 goroutine。

为什么不销毁线程,来节约CPU资源?

因为创建和销毁CPU也会浪费时间

希望当有新 goroutine 创建时,立刻能有 M 运行它

如果销毁再新建就增加了时延,降低了效率。当然也考虑了过多的自旋线程是浪费CPU,所以系统中最多有GOMAXPROCS个自旋的线程

(5)场景5

假定当前除了 M3 和 M4 为自旋线程

还有 M5 和 M6 为空闲的线程(P 的数量应该永远是 M>=P, 大部分都是 M 在抢占需要运行的 P)

G8 创建了 G9,G8 进行了阻塞的系统调用

  • M2 和 P2 立即解绑
    • 如果 P2 本地队列有 G、全局队列有 G 或有空闲的 M,P2 会立马唤醒1个 M 和它绑定
    • 否则 P2 则会加入到空闲 P 列表,等待 M 来获取可用的 P

本场景中,P2 本地队列有 G9,可以和其他空闲的线程 M5 绑定。

G8 创建了 G9,G8 进行了阻塞的系统调用

本场景中,P2 本地队列有 G9,可以和其他空闲的线程 M5 绑定。

M2 和 P2 会解绑,但 M2 会记住 P2,然后 G8 和 M2 进入系统调用状态

当 G8 和 M2 退出系统调用时

  • 尝试获取 P2
    • 如果无法获取,则获取空闲的 P
    • 如果没有空闲 P,G8 会被记为可运行状态,并加入到全局队列,M2 因为没有 P 的绑定而变成休眠状态(长时间休眠等待 GC 回收销毁)。

四、小结

总结,Go 调度器很轻量也很简单,足以撑起 goroutine 的调度工作,并且让 Go 具有了原生(强大)并发的能力。Go 调度本质是把大量的 goroutine 分配到少量线程上去执行,并利用多核并行,实现更强大的并发

Preference

https://github.com/aceld/golang/tree/main

Golang三色标记+混合写屏障GC模式全分析

垃圾回收(Garbage Collection,简称 GC )是自动的内存管理机制,自动释放不需要的对象,让出存储器资源.

Golang 中的垃圾回收主要应用 三色标记法

一、Go V1.3之前的标记-清除(mark and sweep)算法

此算法主要有两个主要的步骤:

  • 标记(Mark phase)
  • 清除(Sweep phase)

第一步,暂停程序业务逻辑 STW(stop the world),分类出可达和不可达的对象,然后做上标记。

图中表示是程序与对象的可达关系,目前程序的可达对象有对象 1-2-3,对象 4-7 等五个对象。

第二步, 开始标记,程序找出它所有可达的对象,并做上标记。如下图所示:

所以对象 1-2-3、对象 4-7 等五个对象被做上标记。

第三步, 标记完了之后,然后开始清除未标记的对象. 结果如下.

Mark and Sweep 算法标记

  • STW 的过程中, CPU 不执行用户代码,全部用于垃圾回收,程序出现卡顿
  • 标记需要扫描整个 heap
  • 清除数据会产生 heap 碎片

第四步, 停止暂停,让程序继续跑。然后循环重复这个过程,直到 process 程序生命周期结束。

Go V1.3 做了简单的优化,将 STW 提前, 减少 STW 暂停的时间范围.如下所示

这里面最重要的问题就是:STW 的过程中, CPU 不执行用户代码,全部用于垃圾回收,程序出现卡顿 。

三、Go V1.5的三色并发标记法

三色标记法 实际上就是通过三个阶段的标记来确定清楚的对象都有哪些

第一步 , 就是只要是新创建的对象,默认的颜色都是标记为“白色”.

第二步, 每次 GC 回收开始, 首先从根节点开始遍历所有对象,把遍历到的对象从白色集合放入“灰色”集合。

第三步, 遍历灰色集合,将灰色对象引用的对象从白色集合放入灰色集合,之后将此灰色对象放入黑色集合

第四步, 重复第三步, 直到灰色中无任何对象.

第五步: 回收所有的白色标记表的对象. 也就是回收垃圾.

以上便是三色并发标记法, 那么又是如何实现并行的呢?

Go是如何解决标记-清除(mark and sweep)算法中的卡顿(stw,stop the world)问题的呢?

四、没有STW的三色标记法

上述的三色并发标记法依赖 STW 的暂停程序

程序的逻辑改变对象引用关系, 这种动作如果在标记阶段做了修改,会影响标记结果的正确性。

如果三色标记法, 标记过程不使用 STW 将会发生什么事情?

可以看出,有两个问题, 在三色标记法中,是不希望被发生的

  • 条件1: 一个白色对象被黑色对象引用**(白色被挂在黑色下)**
  • 条件2: 灰色对象与它之间的可达关系的白色对象遭到破坏**(灰色同时丢了该白色)**

当以上两个条件同时满足时, 就会出现对象丢失现象!

当然, 如果上述中的白色对象3, 如果他还有很多下游对象的话, 也会一并都清理掉.

为了防止这种现象的发生,最简单的方式就是 STW 直接禁止掉其他用户程序对对象引用关系的干扰

如何能在保证对象不丢失的情况下合理的尽可能的提高 GC 效率,减少 STW 时间呢?

五、屏障机制

(1) “强-弱” 三色不变式

  • 强三色不变式

不存在黑色对象引用到白色对象的指针。

  • 弱三色不变式

所有被黑色对象引用的白色对象都处于灰色保护状态.

为了遵循上述的两个方式, 得到了如下具体的两种屏障方式“插入屏障”, “删除屏障”.

(2) 插入屏障

具体操作: 在 A 对象引用 B 对象的时候,B 对象被标记为灰色。(将 B 挂在 A 下游,B 必须被标记为灰色)

满足: 强三色不变式. (不存在黑色对象引用白色对象的情况了, 因为白色会强制变成灰色)

伪码如下:

添加下游对象(当前下游对象 slot, 新下游对象 ptr) {   
  //1
  标记灰色(新下游对象 ptr)   
  
  //2
  当前下游对象 slot = 新下游对象 ptr` 				  
}

黑色对象的内存槽有两种位置, 栈和堆.

栈空间的特点是容量小,但是要求相应速度快

因为函数调用弹出频繁使用, 所以“插入屏障”机制,在栈空间的对象操作中不使用. 而仅仅使用在堆空间对象的操作中.






但是如果栈不添加,当全部三色标记扫描之后,栈上有可能依然存在白色对象被引用的情况(如上图的对象9).

所以要对栈重新进行三色标记扫描, 但这次为了对象不丢失, 要对本次标记扫描启动 STW 暂停. 直到栈空间的三色标记结束.


最后将栈和堆空间 扫描剩余的全部白色节点清除. 这次 STW 大约的时间在 10~100ms.

(3) 删除屏障

具体操作: 被删除的对象,如果自身为灰色或者白色,那么被标记为灰色。

满足: 弱三色不变式. (保护灰色对象到白色对象的路径不会断)

伪代码:

添加下游对象(当前下游对象slot, 新下游对象ptr) {
  //1
  if (当前下游对象slot是灰色 || 当前下游对象slot是白色) {
  		标记灰色(当前下游对象slot)     //slot为被删除对象, 标记为灰色
  }
  
  //2
  当前下游对象slot = 新下游对象ptr
}







这种方式的回收精度低,一个对象即使被删除了最后一个指向它的指针也依旧可以活过这一轮,在下一轮 GC 中被清理掉。

六、Go V1.8的混合写屏障(hybrid write barrier)机制

插入写屏障和删除写屏障的短板:

  • 插入写屏障:结束时需要 STW 来重新扫描栈,标记栈上引用的白色对象的存活;

  • 删除写屏障:回收精度低,GC 开始时 STW 扫描堆栈来记录初始快照,这个过程会保护开始时刻的所有存活对象。

Go V1.8版本引入了混合写屏障机制(hybrid write barrier),避免了对栈re-scan的过程,极大的减少了 STW 的时间。结合了两者的优点。

(1) 混合写屏障规则

具体操作:

1、GC 开始将栈上的对象全部扫描并标记为黑色(之后不再进行第二次重复扫描,无需 STW ),

2、GC 期间,任何在栈上创建的新对象,均为黑色。

3、被删除的对象标记为灰色。

4、被添加的对象标记为灰色。

满足: 变形的弱三色不变式.

伪代码:

添加下游对象(当前下游对象slot, 新下游对象ptr) {
  	//1 
		标记灰色(当前下游对象slot)    //只要当前下游对象被移走,就标记灰色
  	
  	//2 
  	标记灰色(新下游对象ptr)
  		
  	//3
  	当前下游对象slot = 新下游对象ptr
}

这里我们注意, 屏障技术是不在栈上应用的,因为要保证栈的运行效率。

(2) 混合写屏障的具体场景分析

注意混合写屏障是Gc的一种屏障机制,所以只是当程序执行GC的时候,才会触发这种机制。

GC开始:扫描栈区,将可达对象全部标记为黑

场景一: 对象被一个堆对象删除引用,成为栈对象的下游

伪代码

//前提:堆对象4->对象7 = 对象7;  //对象7 被 对象4引用
栈对象1->对象7 = 堆对象7;  //将堆对象7 挂在 栈对象1 下游
堆对象4->对象7 = null;    //对象4 删除引用 对象7

场景二: 对象被一个栈对象删除引用,成为另一个栈对象的下游

伪代码

new 栈对象9;
对象8->对象3 = 对象3;      //将栈对象3 挂在 栈对象9 下游
对象2->对象3 = null;      //对象2 删除引用 对象3

场景三:对象被一个堆对象删除引用,成为另一个堆对象的下游

伪代码

堆对象10->对象7 = 堆对象7;       //将堆对象7 挂在 堆对象10 下游
堆对象4->对象7 = null;         //对象4 删除引用 对象7

场景四:对象从一个栈对象删除引用,成为另一个堆对象的下游

伪代码

堆对象10->对象7 = 堆对象7;       //将堆对象7 挂在 堆对象10 下游
堆对象4->对象7 = null;         //对象4 删除引用 对象7

Golang中的混合写屏障满足弱三色不变式

结合了删除写屏障和插入写屏障的优点

只需要在开始时并发扫描各个 goroutine 的栈,使其变黑并一直保持,这个过程不需要 STW

而标记结束后,因为栈在扫描后始终是黑色的,也无需再进行 re-scan 操作了,减少了 STW 的时间

七、总结

以上便是 Golang 的 GC 全部的标记-清除逻辑及场景演示全过程。

GoV1.3- 普通标记清除法,整体过程需要启动STW,效率极低。

GoV1.5- 三色标记法, 堆空间启动写屏障,栈空间不启动,全部扫描之后,需要重新扫描一次栈(需要 STW),效率普通

GoV1.8- 三色标记法,混合写屏障机制,栈空间不启动,堆空间启动。整个过程几乎不需要 STW,效率较高。

Preference

https://github.com/aceld/golang/tree/main

随机值

math/rand

package main

import (
	"fmt"
	"math/rand"
	"time"
)

func main() {
	s := rand.NewSource(time.Now().Unix())
	rd := rand.New(s)
	fmt.Println(rd.Intn(100))
}
package main

import (
	"crypto/rand"
	"fmt"
	"math/big"
)

func main() {
	// 生成 0 到 10 的随机整数
	randInt, _ := rand.Int(rand.Reader, big.NewInt(11)) // 生成 0 到 10 的随机整数
	randomNum := randInt.Int64()

	fmt.Println(randomNum)
	//生成数组
	td, _ := GenerateRandomBytes(5)
	var ints = make([]int, len(td))
	for i, b := range td {
		ints[i] = int(b)
	}
	fmt.Println(ints)
}

func GenerateRandomBytes(n int) ([]byte, error) {
	b := make([]byte, n)
	_, err := rand.Read(b)
	if err != nil {
		return nil, err
	}
	return b, nil
}

生成随机 Token

在实际业务中经常需要生成随机 Token(如 API Key、会话令牌等),思路是先用 crypto/rand 读取指定字节数的密码学安全随机值,再用 hex.EncodeToString 编码为十六进制字符串。最终字符串长度是字节数的 2 倍,例如传入 32 得到 64 位字符的 Token:

package main

import (
	"crypto/rand"
	"encoding/hex"
	"fmt"
)

func onboardGenerateToken(bytes int) string {
	b := make([]byte, bytes)
	_, _ = rand.Read(b)
	return hex.EncodeToString(b)
}

func main() {
	// 32 字节 -> 64 位十六进制字符
	fmt.Println(onboardGenerateToken(32))
}

输出示例:

9d4f1a2b8c6e3f0d5a7b9c1e4f2d6a8b0c3e5f7d9a1b3c5e7f2d4a6b8c0e2f4d

注意:这里使用的是 crypto/rand(密码学安全)而非 math/rand(伪随机),生成 Token、密钥等安全敏感场景必须使用前者。

可逆编码:用 XXTEA 加密 + base64 输出

上一节的 hex.EncodeToString 是「把随机字节变成字符串」,属于单向展示。如果你想把一个整数 ID 编码成一段不可读、但还能还原回来的字符串(比如新闻 ID、用户 ID 放到 URL 里又不想暴露真实序号),可以走「XXTEA 对称加密 + base64 URL 安全编码」这条路。

package main

import (
	"encoding/base64"
	"fmt"
	"strconv"

	"github.com/xxtea/xxtea-go/xxtea"
)

var CryptoNormalKey = []byte("XXXXXX")

// Example 演示一次加密后再解密,应当原样还原
func Example(str string, key []byte) {
	encrypt_data := EncryptXXTEA(str, key)
	decrypt_data := DecryptXXTEA(encrypt_data, key)
	if str == string(decrypt_data) {
		fmt.Println("success!")
	} else {
		fmt.Println("fail!")
	}
}

func EncryptXXTEA(str string, key []byte) []byte {
	return xxtea.Encrypt([]byte(str), key)
}

func DecryptXXTEA(ciphertext []byte, key []byte) []byte {
	return xxtea.Decrypt(ciphertext, key)
}

// EncodeNewsId 把一个 int64 新闻 ID 编码成不可逆读、但可还原的 base64 字符串
func EncodeNewsId(newsId int64) string {
	if newsId == 0 {
		return ""
	}
	return base64.URLEncoding.EncodeToString(
		EncryptXXTEA(strconv.FormatInt(newsId, 10), CryptoNormalKey),
	)
}

要点:

  • xxtea.Encrypt / xxtea.Decrypt 都是 ([]byte, []byte) []byte,加解密用同一把 key(示例里是 CryptoNormalKey,实际请放到配置里,别硬编码)。
  • strconv.FormatInt(newsId, 10) 先把数字转成十进制字符串,再交给 XXTEA 加密,最后用 base64.URLEncoding 编码成可放进 URL 的字符串(不含 +、/ 这类会破坏 URL 的字符)。
  • 这是可逆的:拿到密文后 base64.URLDecode + xxtea.Decrypt 就能拿回原始 ID。它和前面 hex Token 的区别是——Token 是「随机值不可还原」,这里 ID 是「确定值可还原」。
  • ⚠️ 别把这当成真正的安全加密:密钥写在代码里、XXTEA 也非强加密算法,它只适合做「混淆 / 不暴露顺序」,不能用于保护敏感数据。

依赖:go get github.com/xxtea/xxtea-go/xxtea。

pem 签名校验

背景:PEM 是什么

PEM 是把密钥 / 证书用 Base64 包一层、带 -----BEGIN ...----- 头尾的文本格式。Go 里用 encoding/pem 解码得到 block.Bytes(内部的 DER/ASN.1 字节),再用 crypto/x509 解析成具体的密钥对象。

ed25519 是 Edwards 曲线的数字签名算法,密钥只有 32 字节、速度快、无需复杂参数。下面把「PEM 解析 + ed25519 签名 / 校验」封装成一个 signer 包,分三块看:解析密钥、签名校验、以及怎么生成一对 PEM 密钥。

1. 解析 PEM 里的密钥

私钥走 PKCS#8(x509.ParsePKCS8PrivateKey),公钥走 PKIX(x509.ParsePKIXPublicKey)。pemParse 负责解码并强制校验 BEGIN 头里的类型是否匹配。

package signer

import (
	"crypto/ed25519"
	"crypto/x509"
	"encoding/pem"
	"fmt"
)

func parsePrivateKey(data []byte) (ed25519.PrivateKey, error) {
	pemData, err := pemParse(data, "PRIVATE KEY")
	if err != nil {
		return nil, err
	}
	key, err := x509.ParsePKCS8PrivateKey(pemData)
	if err != nil {
		return nil, err
	}
	priKey, ok := key.(ed25519.PrivateKey)
	if !ok {
		return nil, fmt.Errorf("Could not cast parsed key to ed25519.PrivateKey")
	}
	return priKey, nil
}

func parsePublicKey(data []byte) (ed25519.PublicKey, error) {
	pemData, err := pemParse(data, "PUBLIC KEY")
	if err != nil {
		return nil, err
	}
	key, err := x509.ParsePKIXPublicKey(pemData)
	if err != nil {
		return nil, err
	}
	pubKey, ok := key.(ed25519.PublicKey)
	if !ok {
		return nil, fmt.Errorf("Could not cast parsed key to ed25519.PublicKey")
	}
	return pubKey, nil
}

func pemParse(data []byte, pemType string) ([]byte, error) {
	block, _ := pem.Decode(data)
	if block == nil {
		return nil, fmt.Errorf("No PEM block found")
	}
	if pemType != "" && block.Type != pemType {
		return nil, fmt.Errorf("key's type is '%s', expected '%s'", block.Type, pemType)
	}
	return block.Bytes, nil
}

2. 签名与校验

Sign 用私钥对消息签名,Verify 用公钥验签。注意这里的做法:不是直接对原始 msg 签名,而是先 hash256(SHA-256)得到 32 字节摘要,再让 ed25519 对摘要签名。所以验签时也必须先算同样的 SHA-256 摘要,再调 ed25519.Verify。

package signer

import (
	"crypto"
	"crypto/ed25519"
	"encoding/hex"
	"fmt"

	"github.com/gofiber/fiber/v2/utils"
)

// Sign returns a signature of a message-hash.
// First the message is hashes (sha256) in order to shrink it,
// then the result hash is being signed with the private key.
func Sign(msg []byte, priKey string) (string, error) {
	key, err := parsePrivateKey(utils.UnsafeBytes(priKey))
	if err != nil {
		return "", fmt.Errorf("failed to parse private key: %w", err)
	}
	hashed := hash256(msg)
	signature := ed25519.Sign(key, hashed)
	return hex.EncodeToString(signature), nil
}

// Verify returns true if the given message and signature are valid, else false.
func Verify(msg []byte, sig string, pubKey string) bool {
	signature, err := hex.DecodeString(sig)
	if err != nil {
		return false
	}
	key, err := parsePublicKey(utils.UnsafeBytes(pubKey))
	if err != nil {
		return false
	}
	hashed := hash256(msg)
	return ed25519.Verify(key, hashed, signature)
}

func hash256(msg []byte) []byte {
	hash := crypto.SHA256.New()
	hash.Write(msg)
	return hash.Sum(nil)
}

3. 生成 PEM 密钥对

一段 main 演示怎么现场生成 ed25519 密钥,并分别序列化成 PKCS#8 私钥 / PKIX 公钥,再包成 PEM 打印出来:

package main

import (
	"crypto/ed25519"
	"crypto/rand"
	"crypto/x509"
	"encoding/pem"
	"fmt"
)

func main() {
	publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
	if err != nil {
		panic(err)
	}
	pri, err := x509.MarshalPKCS8PrivateKey(privateKey)
	if err != nil {
		panic(err)
	}
	pub, err := x509.MarshalPKIXPublicKey(publicKey)
	if err != nil {
		panic(err)
	}
	fmt.Printf(
		"%s\n%s",
		pem.EncodeToMemory(&pem.Block{
			Type:  "PUBLIC KEY",
			Bytes: pub,
		}),
		pem.EncodeToMemory(&pem.Block{
			Type:  "PRIVATE KEY",
			Bytes: pri,
		}),
	)
}

注意事项

  • PEM 类型必须匹配:私钥是 PRIVATE KEY(PKCS#8),公钥是 PUBLIC KEY(PKIX / SubjectPublicKeyInfo)。pemParse 会强制校验 block.Type,不匹配直接报错,避免把公钥当私钥用。
  • 先哈希再签名是约定:ed25519 标准本身会对消息做内部哈希(SHA-512),这里额外先 SHA-256 是「先摘要再签名」的自定义约定——签名和验签两端必须都走 hash256,否则验不过。如果你希望直接对原始消息签名,把 ed25519.Sign(key, msg) / ed25519.Verify(key, msg, sig) 即可。
  • utils.UnsafeBytes 的依赖:github.com/gofiber/fiber/v2/utils 的 UnsafeBytes 只是做 string→[]byte 的零拷贝转换。不想引入 gofiber 的话,直接写成 []byte(priKey) 也完全等价。
  • 解析失败是静默设计:Verify 在 hex 解码失败或公钥解析失败时返回 false 而不返回 error,调用方拿不到原因;需要排查时建议先单独用 parsePublicKey 验证 PEM 内容。
  • 原示例里公钥类型断言失败的错误文案误写成了 PublickKey,这里已顺手改为 PublicKey。

Database

mysql

mysql Docker

下载 mysql Docker 镜像

docker pull mysql:latest

Docker 启动本地 mysql 容器

docker run -p 3306:3306 --name root -e MYSQL_ROOT_PASSWORD=123456 -d mysql:latest

进入 Docker 容器,通过用户名和密码操作数据库

docker exec -it root bash

输入用户名/密码,执行 mysql 的命令行代码创建数据库 create database XXX;

mysql -u root -p123456

create database eventLog;

Gorm

Gorm 通过 golang 结构体简化数据库建表和表查询的过程

  • Gorm Golang 结构体能够包含的数据类型
    • Golang 基础类型,uint,int,bool,string
    • 指针,*string
    • 类型别名
    • 自定义类型
  • 结构体参数中首字母大写表示支持导出该表字段,否则该值不支持导出
type User struct {
  ID           uint           // Standard field for the primary key
  Name         string         // A regular string field
  Email        *string        // A pointer to a string, allowing for null values
  Age          uint8          // An unsigned 8-bit integer
  Birthday     *time.Time     // A pointer to time.Time, can be null
  MemberNumber sql.NullString // Uses sql.NullString to handle nullable strings
  ActivatedAt  sql.NullTime   // Uses sql.NullTime for nullable time fields
  CreatedAt    time.Time      // Automatically managed by GORM for creation time
  UpdatedAt    time.Time      // Automatically managed by GORM for update time
  ignored      string         // fields that aren't exported are ignored
}

gorm.Model

GORM 有预定义的数据类型,包含 PrimaryKey 主键、数据被创建、更新和软删除时间

// gorm.Model definition
type Model struct {
  ID        uint           `gorm:"primaryKey"`
  CreatedAt time.Time // Set to current time if it is zero on creating
  UpdatedAt time.Time
  DeletedAt gorm.DeletedAt `gorm:"index"`

}

自定义时间:

  Updated   int64 `gorm:"autoUpdateTime:nano"` // Use unix nano seconds as updating time
  Updated   int64 `gorm:"autoUpdateTime:milli"`// Use unix milli seconds as updating time
  Created   int64 `gorm:"autoCreateTime"`      // Use unix seconds as creating time

gorm 关于结构体数据的权限设置:

type User struct {
  Name string `gorm:"<-:create"` // allow read and create
  Name string `gorm:"<-:update"` // allow read and update
  Name string `gorm:"<-"`        // allow read and write (create and update)
  Name string `gorm:"<-:false"`  // allow read, disable write permission
  Name string `gorm:"->"`        // readonly (disable write permission unless it configured)
  Name string `gorm:"->;<-:create"` // allow read and create
  Name string `gorm:"->:false;<-:create"` // createonly (disabled read from db)
  Name string `gorm:"-"`            // ignore this field when write and read with struct
  Name string `gorm:"-:all"`        // ignore this field when write, read and migrate with struct
  Name string `gorm:"-:migration"`  // ignore this field when migrate with struct
}

GormEmbeddedStruct

GORM 数据结构体支持多结构体嵌套

type Author struct {
  Name  string
  Email string
}

type Blog struct {
  ID      int
  Author  Author `gorm:"embedded"`
  Upvotes int32
}
// equals
type Blog struct {
  ID    int64
  Name  string
  Email string
  Upvotes  int32
}

embeddedPrefix 为嵌入的结构体设置统一的前缀:

type Blog struct {
  ID      int
  Author  Author `gorm:"embedded;embeddedPrefix:author_"`
  Upvotes int32
}
// equals
type Blog struct {
  ID          int64
  AuthorName  string
  AuthorEmail string
  Upvotes     int32
}

MySQL Preference:

MySQLGormCase

StoreChainTxsInMySQL

GormDoc

MongoDB

MongoDB Docker

下载 mongo Docker 镜像

docker pull mongo:latest

Docker 启动本地 mongo 容器

docker run -d -p 27017:27017 --name some-mongo \
	-e MONGO_INITDB_ROOT_USERNAME=mongoadmin \
	-e MONGO_INITDB_ROOT_PASSWORD=secret \
	mongo:latest

Download

https://www.mongodb.com/try/download/community

勾选 MongoDB Compass工具,可视化数据库数据

MongoDB

MongoDB 将数据按照 golang 结构体定义存储成 document,并汇集在集合表中

db.collection.insertOne()
db.collection.insertMany()
db.collection.updateOne() 与 upsert: true 选项一起使用时
db.collection.updateMany() 与 upsert: true 选项一起使用时
db.collection.findAndModify() 与 upsert: true 选项一起使用时
db.collection.findOneAndUpdate() 与 upsert: true 选项一起使用时
db.collection.findOneAndReplace() 与 upsert: true 选项一起使用时
db.collection.bulkWrite()

MongoDB Preference:

MongoDBCase

MongoDoc

MySQL 写操作

ConnDB

dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8&parseTime=True&loc=Local&timeout=%s", mysqlCon.Username, mysqlCon.Password, mysqlCon.Addr, mysqlCon.port, mysqlCon.Db, "10s")
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
	log.Printf("failed to connect database, got error %v", err)
	os.Exit(1)
}
if mysqlCon.BatchSize != 0 {
	db = db.Session(&gorm.Session{CreateBatchSize: mysqlCon.BatchSize})
}

Create Table

Gorm 基于数据结构体创建新的数据表,表结构就是结构体结构

定义 owner 数据表,内部嵌套 gorm 模型以及自定义的三个数据类型

type Owner struct {
	gorm.Model
	Address string `json:"address"`
	Owner   string `json:"owner"`
	Tokenid int64  `json:"tokenid"`
}

创建数据表或者基于结构体更新表结构(在旧表的基础上进行新增新的结构体参数)

// 基于定义的结构体创建数据表
err = db.AutoMigrate(&table.Owner{}, &table.Transfer{})
if err != nil {
	log.Printf("failed to create database, got error %v", err)
	return
}

Insert

Insert Owner By Struct

结构体中的主键会采用默认的递增值

var email = new(string)
*email = "string"
var ts = new(time.Time)
*ts = time.Now()
owner := table.Owner{
	Address: "",
	Token:   0,
	Owner: table.User{
		Name:     "Sam",
		Email:    email,
		Age:      18,
		Birthday: ts,
	},
}
result := db.Create(&owner)
log.Printf("Insert new log with ID = %d, affected rows count = %d, with err = %v", owner.ID, result.RowsAffected, result.Error)

Insert Owner By Table and Map

采用 map 插值的方式,不会补全默认主键的值

// key is the column name of the table, must equal to the struct definition
var owner = map[string]interface{}{
	"Name": "Sam16",
}
result := db.Model(&table.Owner{}).Create(owner)
log.Printf("Insert new log within affected rows count = %d, with err = %v", result.RowsAffected, result.Error)

Insert Owners By Structs

Gorm 支持批量插入数据

var owners = make([]table.Owner, 0)
var owner table.Owner
var email = new(string)
*email = "string"
var ts = new(time.Time)
*ts = time.Now()
for index := 1; index < 5; index++ {
	owner = table.Owner{
		Address: "",
		Token:   int64(index),
		Owner: table.User{
			Name:     fmt.Sprintf("%s%d", "Sam", index),
			Email:    email,
			Age:      18,
			Birthday: ts,
		},
	}
	owners = append(owners, owner)
}
// 批量插入需要传入整个切片
result := db.Create(&owners)
log.Printf("Insert new log with affected rows count = %d, with err = %v", result.RowsAffected, result.Error)

CreateInBatches 支持传入结构体数组和单次限额执行数据的批量插入

  • CreateInBatches(owner, 10)
    • 单次插入仅操作 10 条数据
    • 循环多次,每次操作 10 条,直到将全部 owner 数据插入到库
func BatchInsertOwners(db *gorm.DB, owner []table.Owner, limit int) {
	result := db.CreateInBatches(owner, limit)
	log.Printf("Insert new log with affected rows count = %d, with err = %v", result.RowsAffected, result.Error)
}

单次操作限额可以加在连接上,直接在此次连接中进行限制

db := db.Session(&gorm.Session{CreateBatchSize: 1000})

原生 database/sql 插入(不使用 Gorm)

上面所有写操作都走 Gorm,但 Gorm 底层其实就是 database/sql + MySQL 驱动。如果你不想引入 ORM、想完全掌控 SQL,可以用标准库直接插入。

关于 database/sql 的通用机制——驱动注册(空导入 github.com/go-sql-driver/mysql)、Open 只建连接池不拨号、Ping 探活、连接池配置、占位符差异——都已在 数据库与 SQL 里集中讲解,这里不再重复,只给 MySQL 专属的写法。

连接

dsn 即上面 ConnDB 中拼接的字符串(gorm 的 mysql.Open 与 sql.Open 共用同一套 DSN 格式):

import (
	"database/sql"
	_ "github.com/go-sql-driver/mysql" // 注册驱动名 "mysql"
)

db, err := sql.Open("mysql", dsn)
if err != nil {
	log.Printf("failed to init db: %v", err)
	return
}
defer db.Close()
if err := db.Ping(); err != nil { // 真正的连通性校验,Open 不会帮你连
	log.Printf("failed to ping db: %v", err)
	return
}

单条插入

db.Exec 直接执行 SQL,结果用 LastInsertId()(自增主键) / RowsAffected()(受影响行数)取回,而不是 Gorm 的 result.RowsAffected:

// 参数用 ? 占位符(MySQL 语法;PostgreSQL 用 $1, $2)
result, err := db.Exec(
	"INSERT INTO owners (address, owner, tokenid) VALUES (?, ?, ?)",
	"0xabc", "Sam", 1,
)
if err != nil {
	log.Printf("insert failed: %v", err)
	return
}
id, _ := result.LastInsertId()   // 自增主键
n, _ := result.RowsAffected()    // 受影响行数
log.Printf("inserted id=%d, rows=%d", id, n)

批量插入(预编译 + 循环 Exec)

stmt, err := db.Prepare("INSERT INTO owners (address, owner, tokenid) VALUES (?, ?, ?)")
if err != nil {
	log.Printf("prepare failed: %v", err)
	return
}
defer stmt.Close()

for i := 1; i < 5; i++ {
	if _, err := stmt.Exec("0xabc", fmt.Sprintf("Sam%d", i), int64(i)); err != nil {
		log.Printf("exec failed at %d: %v", i, err)
	}
}

要点:

  • 想要 ORM 映射就用 Gorm(本页上半部分);想要完全掌控 SQL 与最小依赖就用原生 database/sql(本页本节)。
  • 占位符随驱动不同而异:MySQL 用 ?,PostgreSQL(pgx 走 database/sql)用 $1/$2。通用机制见 数据库与 SQL。

MySQL 查询操作

QueryDB

根据条件查询的三种方式,当条件无效时会返回 ErrRecordNotFound 错误

  • First
    • SELECT * FROM users ORDER BY id LIMIT 1;
  • Take
    • SELECT * FROM users LIMIT 1;
  • Last
    • SELECT * FROM users ORDER BY id DESC LIMIT 1;

Find 查询全表并返回条件结果,查询过程不会报错 ErrRecordNotFound db.Limit(1).Find(&user)

First/Last

First/Last 根据主键排序(// no primary key defined, results will be ordered by first field),返回第一条排序数据,将数组传递到指针对象

  • db.First(&user)
  • db.Model(&User{}).First(&result)

根据主键查询

db.First(&user, 10)
// SELECT * FROM users WHERE id = 10;

db.First(&user, "10")
// SELECT * FROM users WHERE id = 10;

db.Find(&users, []int{1,2,3})
// SELECT * FROM users WHERE id IN (1,2,3);

根据条件查询

// Get first matched record
db.Where("name = ?", "jinzhu").First(&user)
// SELECT * FROM users WHERE name = 'jinzhu' ORDER BY id LIMIT 1;

// Get all matched records
db.Where("name <> ?", "jinzhu").Find(&users)
// SELECT * FROM users WHERE name <> 'jinzhu';

// IN
db.Where("name IN ?", []string{"jinzhu", "jinzhu 2"}).Find(&users)
// SELECT * FROM users WHERE name IN ('jinzhu','jinzhu 2');

// LIKE
db.Where("name LIKE ?", "%jin%").Find(&users)
// SELECT * FROM users WHERE name LIKE '%jin%';

// AND
db.Where("name = ? AND age >= ?", "jinzhu", "22").Find(&users)
// SELECT * FROM users WHERE name = 'jinzhu' AND age >= 22;

// Time
db.Where("updated_at > ?", lastWeek).Find(&users)
// SELECT * FROM users WHERE updated_at > '2000-01-01 00:00:00';

// BETWEEN
db.Where("created_at BETWEEN ? AND ?", lastWeek, today).Find(&users)
// SELECT * FROM users WHERE created_at BETWEEN '2000-01-01 00:00:00' AND '2000-01-08 00:00:00';

Query within struct/mapping

// Struct
db.Where(&User{Name: "jinzhu", Age: 20}).First(&user)
// SELECT * FROM users WHERE name = "jinzhu" AND age = 20 ORDER BY id LIMIT 1;

// Map
db.Where(map[string]interface{}{"name": "jinzhu", "age": 20}).Find(&users)
// SELECT * FROM users WHERE name = "jinzhu" AND age = 20;

// Slice of primary keys
db.Where([]int64{20, 21, 22}).Find(&users)
// SELECT * FROM users WHERE id IN (20, 21, 22);

struct 查询中只会查询非空值,不能采用默认值进行查询

db.Where(&User{Name: "jinzhu", Age: 0}).Find(&users)
// SELECT * FROM users WHERE name = "jinzhu";

map 查询支持各种值,包含默认值查询

db.Where(map[string]interface{}{"Name": "jinzhu", "Age": 0}).Find(&users)
// SELECT * FROM users WHERE name = "jinzhu" AND age = 0;

Not

db.Not("name = ?", "jinzhu").First(&user)
// SELECT * FROM users WHERE NOT name = "jinzhu" ORDER BY id LIMIT 1;

// Not In
db.Not(map[string]interface{}{"name": []string{"jinzhu", "jinzhu 2"}}).Find(&users)
// SELECT * FROM users WHERE name NOT IN ("jinzhu", "jinzhu 2");

// Struct
db.Not(User{Name: "jinzhu", Age: 18}).First(&user)
// SELECT * FROM users WHERE name <> "jinzhu" AND age <> 18 ORDER BY id LIMIT 1;

// Not In slice of primary keys
db.Not([]int64{1,2,3}).First(&user)
// SELECT * FROM users WHERE id NOT IN (1,2,3) ORDER BY id LIMIT 1;

Or

db.Where("role = ?", "admin").Or("role = ?", "super_admin").Find(&users)
// SELECT * FROM users WHERE role = 'admin' OR role = 'super_admin';

// Struct
db.Where("name = 'jinzhu'").Or(User{Name: "jinzhu 2", Age: 18}).Find(&users)
// SELECT * FROM users WHERE name = 'jinzhu' OR (name = 'jinzhu 2' AND age = 18);

// Map
db.Where("name = 'jinzhu'").Or(map[string]interface{}{"name": "jinzhu 2", "age": 18}).Find(&users)
// SELECT * FROM users WHERE name = 'jinzhu' OR (name = 'jinzhu 2' AND age = 18);

Order

db.Order("age desc, name").Find(&users)
// SELECT * FROM users ORDER BY age desc, name;

// Multiple orders
db.Order("age desc").Order("name").Find(&users)
// SELECT * FROM users ORDER BY age desc, name;

Limit

limit: 限量查询总数 offset: 查询起点

db.Limit(3).Find(&users)
// SELECT * FROM users LIMIT 3;

// Cancel limit condition with -1
db.Limit(10).Find(&users1).Limit(-1).Find(&users2)
// SELECT * FROM users LIMIT 10; (users1)
// SELECT * FROM users; (users2)

db.Offset(3).Find(&users)
// SELECT * FROM users OFFSET 3;

db.Limit(10).Offset(5).Find(&users)
// SELECT * FROM users OFFSET 5 LIMIT 10;

// Cancel offset condition with -1
db.Offset(10).Find(&users1).Offset(-1).Find(&users2)
// SELECT * FROM users OFFSET 10; (users1)
// SELECT * FROM users; (users2)

原生 SQL:派生列与函数(COALESCE / NULLIF / 计算列)

Gorm 帮我们拼 SQL,但有些查询用原生 SQL 更直接,尤其是需要从已有列「算」出新字段时。例如从 llm_providers 表列出所有提供商,并同时算出「展示名」和「是否配了密钥」:

SELECT
    name,
    COALESCE(NULLIF(display_name, ''), name) AS display_name,
    enabled,
    (api_key IS NOT NULL AND api_key != '') AS has_key
FROM llm_providers
ORDER BY name;

各列含义:

  • name:提供商标识(原表列)
  • COALESCE(NULLIF(display_name, ''), name) AS display_name:展示名。内层 NULLIF(display_name, '') 把空串转成 NULL,外层 COALESCE(..., name) 取第一个非 NULL 值——display_name 为空时回退用 name
  • enabled:是否启用(原表列)
  • (api_key IS NOT NULL AND api_key != '') AS has_key:布尔计算列,密钥非空且非空串时为 true,否则 false

在 Go 里用 database/sql 读取(派生列要对应到普通变量,类型按数据库返回决定):

rows, err := db.Query(`
    SELECT name,
           COALESCE(NULLIF(display_name, ''), name),
           enabled,
           (api_key IS NOT NULL AND api_key != '') AS has_key
    FROM llm_providers ORDER BY name`)
if err != nil {
	log.Printf("query failed: %v", err)
	return
}
defer rows.Close()

for rows.Next() {
	var name, displayName string
	var enabled, hasKey bool
	if err := rows.Scan(&name, &displayName, &enabled, &hasKey); err != nil {
		log.Printf("scan failed: %v", err)
		continue
	}
	log.Printf("provider=%s display=%s enabled=%v hasKey=%v", name, displayName, enabled, hasKey)
}
if err := rows.Err(); err != nil { // 遍历结束必须检查读取过程中的错误
	log.Printf("rows error: %v", err)
}

要点:

  • 计算列(如 has_key)在 Go 端就是一个普通 bool(数据库侧类型通常是 boolean 或 tinyint)
  • 遍历完务必调用 rows.Err() 检查,且别忘了 defer rows.Close()
  • 占位符差异:MySQL 用 ?,PostgreSQL 用 $1/$2;本例无参数,故不涉及。通用机制见 数据库与 SQL

SQL

database/sql 是抽象层,不是数据库实现

刚接触时最容易困惑的一点是:为什么连接 PostgreSQL 用的是标准库的 sql.Open?

db, dbErr = sql.Open("pgx", cfg.Database.PostgresDSN)

这里的 sql 是标准库 database/sql 的简称。它不是某个数据库的实现,而是一层「统一接口 + 连接池」。真正和 PostgreSQL 通信的是 pgx 驱动。分层关系如下:

你的代码            db.Query / db.Exec / db.Begin
   ↓
database/sql        标准库:统一 API + 连接池,不含任何数据库实现
   ↓
pgx 驱动            stdlib 适配层:实现 driver.Driver,走 PostgreSQL 协议
   ↓
PostgreSQL 数据库

两个参数各是什么

  • 第一个参数 "pgx" 是驱动名,不是协议也不是数据库类型。database/sql 内部维护一张驱动表(driver name → driver 实现),sql.Open 按这个名字去查表。
  • 第二个参数是 DSN(数据源名称),对 database/sql 而言完全是不透明字符串,它原样转交给驱动的 Open(dsn),由驱动自己解析。

驱动名是怎么注册进去的

靠空导入——导入驱动包但不使用它的任何导出符号,只为触发它的 init():

import (
	"database/sql"
	_ "github.com/jackc/pgx/v5/stdlib" // 注册驱动名为 "pgx"
)

stdlib 包的 init() 里会执行 sql.Register("pgx", ...),把实现注册进驱动表,sql.Open 才能找到它。

最容易踩的坑:忘了写这个空导入。编译能通过,运行时才报错:

sql: unknown driver "pgx" (forgotten import?)

换驱动只需改驱动名和导入,业务代码不用动——这正是这层抽象的价值。常见对照:

驱动包驱动名
github.com/jackc/pgx/v5/stdlibpgx
github.com/lib/pqpostgres
github.com/go-sql-driver/mysqlmysql

DSN

DSN 就是连接串,例如(也正是 onboard 流程里交互式输入拼装、写入 GOCLAW_POSTGRES_DSN 的那个,见 交互输入 与 opFile):

postgres://user:password@localhost:5432/goclaw?sslmode=disable

陷阱:Open 并不连数据库

sql.Open 只校验参数、创建连接池对象,不会建立任何网络连接。连接是懒建立的——第一次真正执行查询时才拨号。所以 DSN 写错、数据库没起来,Open 照样返回 nil 错误。必须显式探活:

db, err := sql.Open("pgx", cfg.Database.PostgresDSN)
if err != nil {
	return err // 这里只可能是参数或驱动名错误
}
if err := db.Ping(); err != nil {
	return fmt.Errorf("ping postgres: %w", err) // 真正的连通性校验在这里
}

db 用完要关闭,通常配合 defer:

defer db.Close()

连接池配置

默认无上限,高并发下容易把数据库打挂,建议显式配置:

db.SetMaxOpenConns(25)                 // 最大打开连接数
db.SetMaxIdleConns(5)                  // 最大空闲连接数
db.SetConnMaxLifetime(30 * time.Minute) // 连接可复用的最长时间

和 Gorm 的关系

DataBase 里 MySQL 用的是 gorm.Open(mysql.Open(dsn), ...)。Gorm 是 ORM,底层仍然是 database/sql + 驱动——gorm.io/driver/postgres 内部用的正是 pgx。

所以 sql.Open("pgx", dsn) 是「裸写」的等价写法:少一层 ORM 映射,需要自己处理 rows.Scan:

rows, err := db.Query("SELECT address, tokenid FROM owners WHERE tokenid > ?", 100)
if err != nil {
	return err
}
defer rows.Close()

for rows.Next() {
	var address string
	var tokenID int64
	if err := rows.Scan(&address, &tokenID); err != nil {
		return err
	}
	fmt.Println(address, tokenID)
}
// 遍历结束后必须检查一次错误,区分「正常结束」和「迭代中出错」
if err := rows.Err(); err != nil {
	return err
}

注意占位符是驱动相关的:MySQL 用 ?,PostgreSQL 用 $1, $2。传参而不是拼字符串,可以避免 SQL 注入。

补充:pgx 的原生 API

pgx 还有一套原生 API(pgxpool.New(ctx, dsn)),不走 database/sql,性能更好、对 PostgreSQL 特有类型(数组、jsonb、自定义类型)支持更完整。

用 sql.Open("pgx", ...) 通常是为了兼容既有代码或统一接口;新项目如果确定只用 PostgreSQL,可以直接上 pgxpool。

原生 database/sql 的写入示例(MySQL)

上面都是查询,写入同理——database/sql 用 db.Exec 执行写语句,结果取回 LastInsertId()(自增主键)和 RowsAffected()(受影响行数),而不是 Gorm 的 result.RowsAffected。

import (
	"database/sql"
	_ "github.com/go-sql-driver/mysql" // 注册驱动名 "mysql"(空导入,见上文)
)

// dsn 与 gorm 的 mysql.Open 共用同一套格式;Open 只建池不拨号,务必 Ping
db, err := sql.Open("mysql", dsn)
if err != nil {
	return fmt.Errorf("init db: %w", err)
}
defer db.Close()
if err := db.Ping(); err != nil {
	return fmt.Errorf("ping db: %w", err)
}

// 单条插入:? 是 MySQL 占位符,参数化避免 SQL 注入
result, err := db.Exec(
	"INSERT INTO owners (address, owner, tokenid) VALUES (?, ?, ?)",
	"0xabc", "Sam", 1,
)
if err != nil {
	return fmt.Errorf("insert: %w", err)
}
id, _ := result.LastInsertId()
n, _ := result.RowsAffected()
log.Printf("inserted id=%d, rows=%d", id, n)

// 批量插入:预编译一次,循环 Exec 复用
stmt, err := db.Prepare("INSERT INTO owners (address, owner, tokenid) VALUES (?, ?, ?)")
if err != nil {
	return fmt.Errorf("prepare: %w", err)
}
defer stmt.Close()
for i := 1; i < 5; i++ {
	if _, err := stmt.Exec("0xabc", fmt.Sprintf("Sam%d", i), int64(i)); err != nil {
		log.Printf("exec %d failed: %v", i, err)
	}
}

占位符随驱动而异:MySQL 用 ?,PostgreSQL(pgx 走 database/sql)用 $1/$2。上面这些读写示例覆盖了「驱动注册 / 空导入 / 连接池 / 占位符 / Open 与 Ping 陷阱」等全部通用机制;MySQL 的 Gorm 写法(AutoMigrate、db.Create、CreateInBatches)见 MySQL 写操作。

何时用 Gorm,何时用原生 database/sql

  • 用 Gorm:想要结构体↔表映射、AutoMigrate 建表、链式查询、批量插入开箱即用,少写样板代码。代价是多一层依赖与抽象。
  • 用原生 database/sql:想要最小依赖、完全掌控 SQL 与参数、精确管理预编译/事务,或维护一套跨数据库的统一接口(底层仍是各自驱动)。代价是要自己 rows.Scan、自己拼批量逻辑。

Gorm 并没有替代 database/sql——它底层就是 database/sql + 对应驱动。两者不是二选一,而是「ORM 之上」与「裸接口」的层级关系。

OperateFile

本节介绍 Golang 中常用的文件与网络操作封装:CSV 读写、环境变量文件生成、带会话保持的 HTTP 客户端。这些是链上数据落盘、CLI 工具开发中的高频操作。

ReadCSV

读取 CSV 文件的标准流程:os.Open 打开文件 → csv.NewReader 构造读取器 → ReadAll 一次性读入全部行。返回的 lines 是二维切片 [][]string,每个元素是一行的字段列表。

reader.Comma 指定分隔符,默认为 ,,也可以改为 \t 读取 TSV 文件:

file, err := os.Open("file.csv")
if err != nil {
	log.Fatal(err)
}
defer file.Close()

reader := csv.NewReader(file)
reader.Comma = ','

lines, err := reader.ReadAll()
if err != nil {
	log.Fatal(err)
}

for index, line := range lines {
	fmt.Println(index, line) // line 为 []string,对应一行中的各个字段
}

WriteCSV

写入 CSV 的流程:os.OpenFile 以追加模式打开(os.O_CREATE|os.O_WRONLY|os.O_APPEND)→ csv.NewWriter 构造写入器 → 循环 Write 写入行 → defer writer.Flush() 刷盘。

注意 Flush 必须调用,否则数据可能还停留在缓冲区没有落盘。下面的示例将链上交易记录批量导出为 CSV 文件:

func ParseTxToCSV(res *[]gettxbyaddress.Result, apiKey string) {
	file, err := os.OpenFile("file.csv", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
	if err != nil {
		log.Fatal(err)
	}
	defer func(file *os.File) {
		err := file.Close()
		if err != nil {
			log.Fatal(err)
		}
	}(file)

	writer := csv.NewWriter(file)
	defer writer.Flush()

	for _, txRes := range *res {
		ERC20 := checkERC20(txRes.To, apiKey)
		err := writer.Write([]string{
			txRes.BlockNumber,
			txRes.TimeStamp,
			txRes.Hash,
			txRes.Nonce,
			txRes.BlockHash,
			txRes.TransactionIndex,
			txRes.From,
			txRes.To,
			ERC20,
			txRes.Value,
			txRes.Gas,
			txRes.GasPrice,
			txRes.IsError,
			txRes.TxReceiptStatus,
			txRes.Input,
			txRes.ContractAddress,
			txRes.CumulativeGasUsed,
			txRes.GasUsed,
			txRes.Confirmations,
			txRes.MethodId,
			txRes.FunctionName,
		})
		if err != nil {
			log.Fatal(err)
		}
	}
}

WriteEnvFile

生成 .env 风格的配置文件。要点:

  • 用 strings.Join(lines, "\n") 拼接内容,保证末尾换行
  • os.WriteFile 的第三个参数是文件权限,0600 表示仅文件所有者可读写——密钥类配置必须收紧权限,避免被同机其他用户读取
  • 注释中提醒将文件加入 .gitignore,防止敏感信息被提交到仓库
func onboardWriteEnvFile(path, postgresDSN, gatewayToken, encryptionKey string) {
	var lines []string
	lines = append(lines, "# GoClaw — auto-generated by onboard")
	lines = append(lines, "# Keep this file secret! Add to .gitignore.")
	lines = append(lines, "")
	lines = append(lines, fmt.Sprintf("export GOCLAW_POSTGRES_DSN=%s", postgresDSN))
	lines = append(lines, fmt.Sprintf("export GOCLAW_GATEWAY_TOKEN=%s", gatewayToken))
	lines = append(lines, fmt.Sprintf("export GOCLAW_ENCRYPTION_KEY=%s", encryptionKey))
	lines = append(lines, "")

	content := strings.Join(lines, "\n")
	_ = os.WriteFile(path, []byte(content), 0600)
}

HttpWithSameSession

默认的 http.Client 每次请求相互独立、不保留 Cookie。要模拟「同一会话」(例如登录后保持会话连续请求),需要给 http.Client 挂上 cookiejar,由它负责在各次请求间自动存取 Cookie:

  • cookiejar.New(nil) 创建会话存储,Cookie 会按域名自动管理
  • DisableKeepAlives: true 关闭连接复用,每次请求使用新连接,适合配合代理或避免服务端连接限制
  • MaxIdleConnsPerHost: -1 表示不保留每主机空闲连接
  • Timeout: 0 表示不设置整体超时(如需超时控制可设为具体时长)
func createHttpClient() (*http.Client, error) {
	jar, err := cookiejar.New(nil)
	if err != nil {
		return nil, fmt.Errorf("cookiejar.New error %s", err.Error())
	}
	return &http.Client{
		Jar: jar,
		Transport: &http.Transport{
			DisableKeepAlives:   true,
			MaxIdleConnsPerHost: -1,
		},
		Timeout: 0,
	}, nil
}

交互输入

CLI 工具经常需要引导用户输入配置(数据库连接信息、密钥等)。直接用 fmt.Scan 体验很差,huh 是 Charm 生态下的表单库,可以用少量代码构建出带标题、占位符、密码掩码和帮助提示的终端交互表单。

安装:

go get github.com/charmbracelet/huh

核心概念:

  • huh.NewInput() 创建一个输入框,Title 设置提示标题,Value(&v) 把用户输入绑定到变量
  • EchoMode(huh.EchoModePassword) 让输入以 * 掩码显示,适合密码字段
  • huh.NewForm(huh.NewGroup(inp)).Run() 运行表单,一次可以包含多个输入项
  • 按 Ctrl+C 取消时 Run 返回 err,上层据此优雅退出

下面的示例来自 onboard 初始化命令:引导用户输入 PostgreSQL 连接信息(直接回车采用默认值),最后拼装成 DSN 连接串。promptString 的逻辑是「空输入返回默认值」,promptPassword 则使用密码掩码模式:

package main

import (
	"fmt"
	"net/url"
	"os"

	"github.com/charmbracelet/huh"
)

func main() {
	fmt.Println("── Database Connection ──")
	fmt.Println("  Enter your PostgreSQL connection details (press Enter for defaults).")
	fmt.Println()

	dsn, err := promptPostgresFields()
	if err != nil {
		fmt.Println("Cancelled.")
		os.Exit(1)
	}

	fmt.Println()
	fmt.Println("  Assembled DSN:")
	fmt.Printf("    %s\n", dsn)
}

// promptPostgresFields 对应 goclaw/cmd/onboard_helpers.go。
func promptPostgresFields() (string, error) {
	host, err := promptString("Host", "", "localhost")
	if err != nil {
		return "", err
	}
	port, err := promptString("Port", "", "5432")
	if err != nil {
		return "", err
	}
	dbName, err := promptString("Database name", "", "goclaw")
	if err != nil {
		return "", err
	}
	user, err := promptString("Username", "", "postgres")
	if err != nil {
		return "", err
	}
	password, err := promptPassword("Password", "Leave empty if no password")
	if err != nil {
		return "", err
	}
	sslMode, err := promptString("SSL mode", "", "disable")
	if err != nil {
		return "", err
	}

	var userInfo *url.Userinfo
	if password != "" {
		userInfo = url.UserPassword(user, password)
	} else {
		userInfo = url.User(user)
	}
	dsn := fmt.Sprintf("postgres://%s@%s:%s/%s?sslmode=%s", userInfo.String(), host, port, dbName, sslMode)
	return dsn, nil
}

// promptString:空输入(直接 Enter)返回 defaultVal。对照 goclaw/cmd/prompt.go。
func promptString(title, description, defaultVal string) (string, error) {
	var value string
	inp := huh.NewInput().
		Title(title).
		Value(&value)

	if description != "" {
		inp = inp.Description(description)
	}
	if defaultVal != "" {
		inp = inp.Placeholder(defaultVal)
	}

	if err := huh.NewForm(huh.NewGroup(inp)).WithShowHelp(true).Run(); err != nil {
		return "", err
	}
	if value == "" {
		return defaultVal, nil
	}
	return value, nil
}

func promptPassword(title, description string) (string, error) {
	var value string
	inp := huh.NewInput().
		Title(title).
		EchoMode(huh.EchoModePassword).
		Value(&value)

	if description != "" {
		inp = inp.Description(description)
	}

	if err := huh.NewForm(huh.NewGroup(inp)).WithShowHelp(true).Run(); err != nil {
		return "", err
	}
	return value, nil
}

WebSocket

核心认知:WebSocket 只传字节

这是使用 WebSocket 时最重要的一点,必须先理清:

WebSocket 本身只负责传输字节(帧),它不关心、也不知道你在传什么。请求和响应的 JSON 形状是应用层协议,必须由提供服务的后端先定下来,客户端再按同一套字段去编解码。

具体来说:

  1. 传输层:WebSocket 只保证「把一段字节从 A 送到 B」,并且是全双工、长连接。它既不规定消息是 JSON 还是 protobuf,也不规定字段叫什么。
  2. 应用层协议:{"type":"req","id":"...","method":"...","params":{...}} 这样的字段结构,是服务端自己定义的一套约定(本例中就是网关的 RPC 协议)。服务端怎么定,客户端就必须怎么写——字段名、类型、含义全都要对齐,写错一个字母就解不出来。
  3. 实践推论:对接任何 WebSocket 服务时,第一件事是拿到服务端的协议文档(或源码里的结构体定义),照着写客户端的结构体 tag。不要凭猜想去试字段名。
  4. 对比 JSON-RPC:以太坊的 eth_subscribe、eth_call 走的是同一套思路——传输层可以是 HTTP 也可以是 WebSocket,但 jsonrpc/id/method/params 这套应用层格式是固定的,与传输方式无关。

因此下面的示例可以拆成两层看:帧的收发(WriteJSON / ReadJSON,通用)和协议结构体(requestFrame / responseFrame,本服务专属)。

协议定义(应用层)

先定义服务端约定好的请求/响应结构,这就是「应用层协议」的落地:

type requestFrame struct {
	Type   string          `json:"type"`
	ID     string          `json:"id"`
	Method string          `json:"method"`
	Params json.RawMessage `json:"params,omitempty"`
}

type responseFrame struct {
	Type    string `json:"type"`
	ID      string `json:"id"`
	OK      bool   `json:"ok"`
	Payload any    `json:"payload,omitempty"`
	Error   *struct {
		Code    string `json:"code"`
		Message string `json:"message"`
	} `json:"error,omitempty"`
}

要点:

  • Params json.RawMessage 表示「原样保留、暂不解析」的 JSON 片段,因为不同 method 的参数结构不同,客户端框架层不需要知道细节,直接透传即可。
  • Payload any 同理——响应的业务数据形状由具体 method 决定,用 any 接住再二次解析。
  • omitempty 表示字段为空时不序列化,避免发出 null 让服务端解析出问题。
  • Error 用指针,是为了区分「没出错」(nil)和「出错了但错误信息为零值」。

完整示例

流程是:连接 → 发 connect 握手 → 读握手响应 → 发业务 RPC → 循环读响应直到读到匹配的 id。

package main

import (
	"encoding/json"
	"fmt"
	"os"
	"strings"
	"time"

	"github.com/gorilla/websocket"
)

const protocolVersion = 3

type requestFrame struct {
	Type   string          `json:"type"`
	ID     string          `json:"id"`
	Method string          `json:"method"`
	Params json.RawMessage `json:"params,omitempty"`
}

type responseFrame struct {
	Type    string `json:"type"`
	ID      string `json:"id"`
	OK      bool   `json:"ok"`
	Payload any    `json:"payload,omitempty"`
	Error   *struct {
		Code    string `json:"code"`
		Message string `json:"message"`
	} `json:"error,omitempty"`
}

func main() {
	wsURL := gatewayWSURL()
	token := os.Getenv("GOCLAW_GATEWAY_TOKEN")

	fmt.Printf("Dial %s\n", wsURL)
	conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
	if err != nil {
		fmt.Printf("dial: %v\n", err)
		os.Exit(1)
	}
	defer conn.Close()

	// 1) connect 握手
	connectParams, _ := json.Marshal(map[string]any{
		"token":    token,
		"protocol": protocolVersion,
	})
	if err := conn.WriteJSON(requestFrame{
		Type:   "req",
		ID:     "cli-connect",
		Method: "connect",
		Params: connectParams,
	}); err != nil {
		fmt.Printf("send connect: %v\n", err)
		os.Exit(1)
	}

	// 只约束「读 connect 回复」:5s 内 ReadJSON 完不成 → i/o timeout,随后 defer Close
	conn.SetReadDeadline(time.Now().Add(5 * time.Second))
	var connectResp responseFrame
	if err := conn.ReadJSON(&connectResp); err != nil {
		fmt.Printf("read connect response: %v\n", err)
		os.Exit(1)
	}
	if !connectResp.OK {
		msg := "unknown error"
		if connectResp.Error != nil {
			msg = connectResp.Error.Message
		}
		fmt.Printf("connect failed: %s\n", msg)
		os.Exit(1)
	}
	fmt.Println("connect OK")

	// 2) 业务 RPC:这里用 health,pairing CLI 则是 pairing.list / approve
	if err := conn.WriteJSON(requestFrame{
		Type:   "req",
		ID:     "cli-rpc",
		Method: "health",
	}); err != nil {
		fmt.Printf("send health: %v\n", err)
		os.Exit(1)
	}

	conn.SetReadDeadline(time.Now().Add(10 * time.Second))
	for {
		_, msg, err := conn.ReadMessage()
		if err != nil {
			fmt.Printf("read response: %v\n", err)
			os.Exit(1)
		}

		var peek struct {
			Type string `json:"type"`
		}
		if json.Unmarshal(msg, &peek) != nil {
			continue
		}
		if peek.Type == "event" {
			continue // 跳过服务端主动推送的 event
		}

		var resp responseFrame
		if err := json.Unmarshal(msg, &resp); err != nil {
			fmt.Printf("parse response: %v\n", err)
			os.Exit(1)
		}
		if resp.ID != "cli-rpc" {
			continue
		}

		out, _ := json.MarshalIndent(resp, "", "  ")
		fmt.Println(string(out))
		return
	}
}

func gatewayWSURL() string {
	base := os.Getenv("GOCLAW_GATEWAY_URL")
	if base == "" {
		base = "http://127.0.0.1:18790"
	}
	base = strings.TrimRight(base, "/")
	switch {
	case strings.HasPrefix(base, "https://"):
		return "wss://" + strings.TrimPrefix(base, "https://") + "/ws"
	case strings.HasPrefix(base, "http://"):
		return "ws://" + strings.TrimPrefix(base, "http://") + "/ws"
	case strings.HasPrefix(base, "ws://") || strings.HasPrefix(base, "wss://"):
		if strings.HasSuffix(base, "/ws") {
			return base
		}
		return base + "/ws"
	default:
		return "ws://" + base + "/ws"
	}
}

运行方式(依赖 gorilla/websocket):

go get github.com/gorilla/websocket
export GOCLAW_GATEWAY_TOKEN=...
go run .

代码逐段解析

1. URL 转换 gatewayWSURL

WebSocket 的地址由 HTTP 地址派生:http:// → ws://,https:// → wss://(带 TLS)。这个函数做了健壮性处理——无论环境变量里填的是 http://、ws:// 还是裸 host:port,都能归一化成正确的 ws 地址并补上 /ws 路径。这类小函数值得抽出来,避免调用方各自拼字符串出错。

2. 拨号与连接关闭

conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
defer conn.Close()

Dial 完成的就是 HTTP Upgrade 握手(发 Upgrade: websocket 头,服务端回 101 Switching Protocols)。握手成功后连接就转为 WebSocket 长连接。defer conn.Close() 必须写——否则连接泄漏,服务端会一直持有。

3. 握手帧 connect

WebSocket 协议本身没有认证机制,所以「认证」属于应用层协议的一部分:这里服务端约定,连接建立后第一条消息必须是 method: "connect" 且携带 token 和 protocol 版本号。服务端据此校验身份、协商协议版本,不匹配就拒绝。

4. 读超时 SetReadDeadline

conn.SetReadDeadline(time.Now().Add(5 * time.Second))

这是本示例最值得学习的细节之一。WebSocket 是长连接,服务端可能迟迟不回,ReadJSON 会一直阻塞导致程序挂死。设置读截止时间的含义是:只对「这一次读取」生效——5 秒内没读到就返回 i/o timeout 错误,然后 defer Close 兜底关闭连接。

注意它是一次性的、针对读操作的绝对时间点,所以每次关键读取前都要重新设一次(示例第二步又设了 10s)。在 Web3 场景监听链上事件时同样必须设置,否则 RPC 节点无响应会让程序永久卡住。

5. 循环读取与消息过滤

for {
	_, msg, err := conn.ReadMessage()
	...
	if peek.Type == "event" {
		continue // 跳过服务端主动推送的 event
	}
	...
	if resp.ID != "cli-rpc" {
		continue
	}
	...
	return
}

这里体现了全双工带来的一个现实问题:你可能收到不是你要的消息。服务端会主动推送 event(通知类消息),也可能因为异步返回顺序错乱而先收到别的请求的响应。所以必须做两轮过滤:

  • 先用一个轻量结构体 peek 只解出 type 字段(这就是「先窥一眼再决定怎么处理」的常用技巧,避免对无关消息做完整解析),是 event 就跳过;
  • 再完整解码,比对 ID 是否等于自己发出的 cli-rpc,不是就继续等。

这正是「请求带 id」这一应用层设计的价值——请求和响应靠 id 配对,而不是靠顺序。

小结

  • WebSocket 是字节管道,JSON 形状是应用层协议,字段由服务端定义、客户端照抄,二者不可混淆
  • 用 id 做请求-响应配对,用 type 区分响应与服务端推送事件
  • 每次关键读取都要 SetReadDeadline,避免长连接把程序挂死
  • defer conn.Close() + omitempty + 指针 Error 是这类代码的固定套路

发邮件:SendGrid 事务邮件

背景:为什么不直接用 SMTP

发邮件有两种姿势:

  1. 自建 SMTP(自己的 Postfix / 云厂商邮件服务):要自己管发信域名、SPF / DKIM / DMARC 三件套、退信处理、IP 信誉,否则进垃圾箱概率极高。
  2. 事务邮件服务(SendGrid / SES / Mailgun):把「送达率」问题外包出去,只关心「谁发给谁、内容是什么」。

本项目的发信层基于 SendGrid,核心优势有二:

  • Dynamic Templates(动态模板):邮件正文是 SendGrid 后台的一份 HTML 模板,由 template_id 引用;Go 代码只负责塞变量(code、to_email)。
  • 改文案不动代码:HTML 在 SendGrid 后台改完,下一封邮件就会用新文案,不需要重新编译或发布 Go 服务。

代码结构

整个 email 包就是「构造 mail.Email 收发件人 + 用模板渲染正文 + 走 SendGrid 客户端发出去」这一条链路的封装。

package email

import (
	"github.com/phuslu/log"
	"github.com/sendgrid/sendgrid-go"
	"github.com/sendgrid/sendgrid-go/helpers/mail"
	"space.nation/config"
)

// FromNoReply 系统自动邮件的统一发件人(验证码、通知等)
func FromNoReply() *mail.Email {
	return mail.NewEmail("XXX", "noreply@xx.xx")
}

// FromSupport 用户回信时的 Reply-To 地址,方便收件人直接回复到人工客服
func FromSupport() *mail.Email {
	return mail.NewEmail("XXX", "support@xxx.com")
}

// ToEmail 把字符串地址解析为 *mail.Email,顺便做格式校验
func ToEmail(email string) (*mail.Email, error) {
	return mail.ParseEmail(email)
}

// ToUser 同 ToEmail,但额外带上收件人显示名(如 "Alice")
func ToUser(name string, email string) (*mail.Email, error) {
	m, err := mail.ParseEmail(email)
	if err != nil {
		return nil, err
	}
	m.Name = name
	return m, nil
}

发件人固定成两个函数,避免到处硬编码邮箱字符串;收件人用 ParseEmail 解析,格式非法直接报错,把「脏邮箱」挡在发送之前。

发送验证码(模板邮件)

// SendEmailVerifyCode 发送注册/登录验证码。
// 正文由 SendGrid 后台的 Dynamic Template(d-df345w...)渲染,
// 这里只注入两个变量:收件人地址和验证码。
func SendEmailVerifyCode(to *mail.Email, code string) error {
	m := mail.NewV3Mail()
	m.SetFrom(FromNoReply())
	m.SetReplyTo(FromSupport()) // 用户点回复会回到人工客服

	p := mail.NewPersonalization()
	p.AddTos(to)
	p.SetDynamicTemplateData("to_email", to.Address)
	p.SetDynamicTemplateData("code", code)
	m.AddPersonalizations(p)

	m.SetTemplateID("d-qwerpskdfg94jgxxxxx..xx")
	return SendMessage(m)
}

要点:

  • NewV3Mail + SetTemplateID 走的是 Transactional Template(v3),正文完全由后台模板决定,Go 侧不拼 HTML。
  • SetDynamicTemplateData(key, value) 注入模板里的 {{to_email}}、{{code}} 占位符。
  • SetReplyTo(FromSupport()):用户回信进人工客服,而不是 noreply 黑洞。

统一出口:SendMessage

// SendMessage 所有邮件的统一发送入口。
func SendMessage(message *mail.SGMailV3) error {
	client := sendgrid.NewSendClient(config.SendGridKey())
	response, err := client.Send(message)
	if err != nil {
		return err
	}
	// 202 Accepted 表示 SendGrid 已接收并进入发送队列,是成功态。
	// 非 202(如 401 密钥错、429 限流、4xx 模板/发件人问题)记录日志以便排查。
	if response.StatusCode != 202 {
		log.Info().Msgf("SendGrid status code: %d", response.StatusCode)
	}
	return nil
}
  • config.SendGridKey() 从配置/环境变量读取 API Key,不要硬编码进仓库。
  • client.Send 返回的 err 是「网络/SDK 层」错误;HTTP 状态码走 response.StatusCode。SendGrid 成功是 202,不是常见的 200。
  • 这里只 log 非 202、不返回 error:是否把「发送排队但未确认送达」当成业务失败,取决于你的容忍度。若希望严格,可以把非 202 也 return fmt.Errorf(...)。

改邮件文案:不用发版 Go

模板正文在 SendGrid 后台维护,这是这层封装最大的好处:

  1. 登录 SendGrid → Dynamic Templates → 找到对应的 template ID(d-erwetnig87huwuy4tiwh...fwe)→ 改 HTML。
  2. 改完不用发版 Go 服务,下一封触发 SendEmailVerifyCode 的邮件就会用上新文案。

注意:Go 代码里的 template ID 要和实际模板对应;如果从后台新建了一份模板,记得把新的 d-xxxx 同步到 SetTemplateID 这里——这一步才需要改代码。

扩展:再加一种邮件

照着 SendEmailVerifyCode 抄一个函数即可,差异只有「用哪个模板 + 注入哪些变量」:

func SendEmailWelcome(to *mail.Email) error {
	m := mail.NewV3Mail()
	m.SetFrom(FromNoReply())
	m.SetReplyTo(FromSupport())

	p := mail.NewPersonalization()
	p.AddTos(to)
	p.SetDynamicTemplateData("to_email", to.Address)
	m.AddPersonalizations(p)

	m.SetTemplateID("d-welcome-template-id") // 换成欢迎邮件的模板 ID
	return SendMessage(m)
}

小结

  • email 包把「发件人 / 收件人 / 模板变量 / 客户端」四件事拆开,调用方只写业务语义(SendEmailVerifyCode)。
  • 正文与代码解耦:文案在 SendGrid 后台改,改 HTML 不动 Go、不发版。
  • 202 是发送成功态;API Key 走配置,别进版本库。

RBAC 与 Casbin

先把一对概念分开:认证 vs 授权

访问控制里最容易混的一对概念先分开:

  • 认证(Authentication):管「你是谁」——登录、验证签名、校验 token,产出的是一个「身份」。
  • 授权(Authorization / Access Control):管「你能干什么」——拿到身份之后,判断这个身份能不能做这件事。

RBAC 属于后者。本仓库前面讲过的「Ethereum 验证签名」「JWT 校验」都属于认证;这一章讲的 RBAC 是它们之后的那一步。

RBAC 是什么

RBAC = Role-Based Access Control,基于角色的访问控制。

核心思想一句话:不直接给人发权限,而是把权限打包成「角色」,再把角色发给用户。

为什么要在中间隔一层「角色」

如果直接给用户发权限(alice: [read, write]、bob: [read]),会立刻遇到几个问题:

  • 组合爆炸:1000 个用户 × 50 项权限,映射关系是 N×M;每次调整都要逐人改。
  • 无法批量:新来 10 个运营,要手工给 10 个人勾同样的权限。
  • 调岗即重配:人从「内容编辑」转「审核」,权限要一条条减一条条加。
  • 审计困难:想知道「有没有人越权」,得逐人核对长列表。
  • 语义丢失:权限列表里看不到「这个人是什么岗位」,只有一堆零散动作。

引入角色后,N×M 的映射被拆成两段:用户 → 角色(N×1)和 角色 → 权限(1×M)。

没有角色:  alice ─────────┬──► read
                          ├──► write
           bob ───────────┼──► read
                          └──► ...     (关系随人增多而膨胀)

有角色:    alice ──► reader ──► read
           bob   ──► writer ──► write   (改权限只动角色这一层)

好处是:给用户加权限 = 加一个角色;改一批人的权限 = 改角色对应的权限;审计时看「谁是什么角色」就够了。

RBAC 的三个要素(外加一层继承)

要素英文含义
用户User / Subject谁在发起访问
角色Role权限的集合,也是权限的分配单位
权限Permission对某个对象做某个操作,如 data → read
角色继承Role Hierarchy角色可以「包含」角色(RBAC1),如 admin 继承 editor

注意:Casbin 里 g 规则负责的正是角色分配与继承,所以「用户 → 角色」和「角色 → 角色」用的是同一套语法。

Casbin:把 RBAC 落成配置

Casbin 是一个「访问控制模型框架」,它把 RBAC 拆成三样东西:

概念作用对应截图区域
Model(模型)描述「怎么判定」,与业务无关中间「模型」面板
Policy(策略)描述「谁拥有什么」,随业务变化右上「策略」面板
Request(请求)一次具体的「想干什么」左下「请求」面板

关键在于模型与策略分离:判定逻辑写在 model 里(一般很少动),业务数据写在 policy 里(可以放数据库、随时改、热更新)。同一套 model,换一份 policy 就是另一套权限体系。

下面是 Casbin 官方在线编辑器里的一个完整 RBAC 例子(编辑器界面):

(在线编辑器地址:https://casbin.org/editor/。截图里右下角显示 Node-Casbin 5.37.0——各语言绑定语法一致,Go 版就是 github.com/casbin/casbin/v2。)

模型面板逐行拆解

[request_definition]
r = sub, obj, act        # 请求三元组:主体(谁) / 对象(操作什么) / 动作(怎么操作)

[policy_definition]
p = sub, obj, act        # 策略三元组,与请求同构,便于逐字段比较

[role_definition]
g = _, _                 # 角色定义函数 g:前两个下划线是「用户, 角色」两个参数

[policy_effect]
e = some(where (p.eft == allow))   # 只要有一条策略命中且为 allow 就放行

[matchers]
m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act

三个字段的含义:

  • g = _, _ 声明了一个叫 g 的角色判定函数。g(r.sub, p.sub) 问的是:「r.sub 是否拥有 p.sub 这个角色(含继承)」。这正是 RBAC 的落点——请求里的主体和策略里的主体不需要相等,只要存在角色关系即可。
  • r.obj == p.obj && r.act == p.act:对象和动作要精确相等。想让它们也变模糊(比如 /api/user/123 匹配 /api/user/*),就换成一个自定义函数。
  • e = some(where (p.eft == allow)) 是「命即放行」的语义。注意它的默认行为是只认 allow:没有任何策略命中时结果是 false(拒绝);要支持「显式 deny 优先」,得改成 !some(where (p.eft == deny)) 这类写法。

截图左上角还有个「自定义函数」面板,定义了 my_func(arg1, arg2) = arg1.endsWith(arg2)。它就是给 matchers 准备的:把 r.obj == p.obj 换成 my_func(r.obj, p.obj),就能用「后缀匹配」做对象判定(例如按资源前缀分组授权)。模型不够用时,靠自定义函数扩展,而不是去改 Casbin 源码。

策略面板

p, reader, data, read
p, writer, data, write
p, admin,  data, delete

g, alice, reader
g, bob,   writer
g, bob,   reader
g, cathy, admin
  • p 行是「角色 → 权限」:p, reader, data, read 表示「reader 角色可以对 data 执行 read」。
  • g 行是「用户 → 角色」:g, bob, writer 表示 bob 拥有 writer 角色。
  • bob 有两条 g 行(writer 和 reader),这就是「一个用户多角色」——RBAC 允许,且 Casbin 天然支持,多条 g 是「或」的关系。

角色继承图(截图左下)

alice ──► reader ──► data:read
bob   ──► writer ──► data:write
          reader ──► data:read
cathy ──► admin  ──► data:delete

这张图就是上面 g 行与 p 行的可视化:用户连到角色,角色连到权限。alice 只挂了 reader,bob 同时挂了 writer 和 reader,cathy 挂了 admin。

请求与结果

请求面板列出的四行,按上面的模型逐条判定:

请求 (sub, obj, act)结果命中原因
alice, data, read✅ trueg(alice, reader) 成立,命中 p, reader, data, read
alice, data, write❌ falsealice 不是 writer;g(alice, reader) 虽成立,但 act 是 write≠read,不匹配
alice, data, delete❌ falsealice 不是 admin
bob, data, write✅ trueg(bob, writer) 成立,命中 p, writer, data, write

如果把 bob, data, read 也拿去问,结果同样是 true——因为 bob 还有 g, bob, reader。一个用户多角色,任一角色命中即放行。

一个小知识点:截图「执行结果」面板里出现了 {"reader","data","read"} 这样的请求——角色名本身也能当 subject 传入,因为 g 是自反的:g(reader, reader) 成立(一个角色当然拥有它自己)。这提醒我们:Enforce 的 sub 传角色名不会报错,但也意味着别把用户可控的输入直接当 sub 传。

用 Go 跑起来

模型存成 model.conf:

[request_definition]
r = sub, obj, act

[policy_definition]
p = sub, obj, act

[role_definition]
g = _, _

[policy_effect]
e = some(where (p.eft == allow))

[matchers]
m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act

策略存成 policy.csv:

p, reader, data, read
p, writer, data, write
p, admin,  data, delete
g, alice, reader
g, bob,   writer
g, bob,   reader
g, cathy, admin

这个 model.conf 不是启动时必须的——它只是「Model 的来源」之一。Casbin 启动只需要一个 model.Model 对象:你可以从文件读(上面这种),也可以用 Go 代码在内存里构造出完全等价的模型,二者二选一。

模型也能直接用 Go 代码构造(无需 model.conf 文件)

如果不想维护 .conf 文件,可以用 model.NewModel() + AddDef 把同样的模型在代码里搭出来:

import "github.com/casbin/casbin/v2/model"

func CasbinModel() model.Model {
	m := model.NewModel()
	m.AddDef("r", "r", "sub, obj, act")
	m.AddDef("p", "p", "sub, obj, act")
	m.AddDef("g", "g", "_, _")
	m.AddDef("e", "e", "some(where (p.eft == allow))")
	m.AddDef("m", "m", "g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act || r.sub == \"root\"")
	return m
}

AddDef(section, key, value) 的每个调用,都精确对应 model.conf 里的一行:

代码等价的 model.conf
m.AddDef("r", "r", ...)[request_definition] r = ...
m.AddDef("p", "p", ...)[policy_definition] p = ...
m.AddDef("g", "g", ...)[role_definition] g = ...
m.AddDef("e", "e", ...)[policy_effect] e = ...
m.AddDef("m", "m", ...)[matchers] m = ...

注意这里 matchers 比前面的 model.conf 多了一段 || r.sub == "root":

g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act || r.sub == "root"

这是一句「超级管理员兜底」——只要 sub 是 "root",无论角色、对象、动作是什么,一律放行。它逻辑上等价于「root 是上帝角色」,但写在 matchers 里而非 policy 里,所以不占 policy 表、也绕过了角色体系。使用时务必小心:任何把 sub 设成 "root" 的请求都会通过授权,因此必须保证 sub 来自可信的认证结果、"root" 不会被普通用户伪造。

把代码模型接到 Enforcer(策略来源换成任意 adapter,不一定要文件):

import (
	"github.com/casbin/casbin/v2"
	"github.com/casbin/casbin/v2/model"
	fileadapter "github.com/casbin/casbin/v2/file-adapter"
)

func main() {
	a := fileadapter.NewAdapter("policy.csv") // 生产可换成 gormadapter / redisadapter 等
	e, err := casbin.NewEnforcer(CasbinModel(), a)
	if err != nil {
		panic(err)
	}
	// 之后用法与文件版完全一致:e.Enforce("alice", "data", "read")
}

NewEnforcer 的第一个参数是「模型(怎么判定)」,第二个参数就是「策略存储适配器(policy 存在哪)」——也就是你自定义的 NewAdapter() 返回的那个 persist.BatchAdapter。它的职责只有一件:读写 policy 表(即 p 行和 g 行那批规则)。具体发生两件事:

  • 初始化时:NewEnforcer 会调用 adapter.LoadPolicy(),把数据库里的所有 p/g 规则拉进 Enforcer 的内存策略表(所以你不必再手动 LoadPolicy)。之后 Enforce 判定只读内存、不每次查库,因此很快。
  • 运行时:e.AddPolicy(...) / e.AddRoleForUser(...) / e.RemovePolicy(...) 这类写操作,会经 BatchAdapter 批量落回数据库;e.LoadPolicy() 则可手动触发一次全量刷新(常用于「管理员在后台改了权限、想立刻生效」,或配合 watcher 自动刷新)。

对照你贴的 adapter 实现:timeout 是每次 MongoDB 读写的 context 超时(防止慢库把鉴权链路拖死),filtered: false 表示它加载全部策略而非按租户过滤的子集(多租户需要 LoadFilteredPolicy 时才把 filtered 置为 true)。

模型与适配器分工一句话:模型决定「怎么判」,适配器决定「规则从哪来、改了存到哪」;二者都齐了,Enforcer 才完整。

两种方式怎么选?模型(怎么判定)几乎不变,写在 model.conf 文件里最省事、也方便非 Go 同学(运维/安全)核对;策略(谁有什么)经常变,应放进数据库由 adapter 加载。代码构造模型适合「模型需要随配置动态拼装」的场景。无论哪种,最终喂给 NewEnforcer 的都是「一个 Model + 一个 Adapter」。

Go 侧只需要一个 Enforcer:

package main

import (
	"fmt"

	"github.com/casbin/casbin/v2"
)

func main() {
	e, err := casbin.NewEnforcer("model.conf", "policy.csv")
	if err != nil {
		panic(err)
	}

	// 通常策略来自数据库而非文件;这里演示运行时增删
	e.AddPolicy("reader", "data", "read")
	e.AddRoleForUser("alice", "reader")          // 用户 → 角色
	e.AddRoleForUser("bob", "writer")            // 一个用户可以有多个角色
	e.AddRoleForUser("bob", "reader")

	requests := [][]any{
		{"alice", "data", "read"},
		{"alice", "data", "write"},
		{"alice", "data", "delete"},
		{"bob", "data", "write"},
		{"bob", "data", "read"},
	}

	for _, r := range requests {
		ok, err := e.Enforce(r...)
		if err != nil {
			fmt.Printf("%v => error: %v\n", r, err)
			continue
		}
		fmt.Printf("%-6v %-6v %-8v => %v\n", r[0], r[1], r[2], ok)
	}
}

输出:

alice  data   read     => true
alice  data   write    => false
alice  data   delete   => false
bob    data   write    => true
bob    data   read     => true

在业务里通常封装成一个中间件,把「已认证的身份」当作 sub 传进来:

func Authorize(e *casbin.Enforcer, obj, act string) fiber.Handler {
	return func(c *fiber.Ctx) error {
		sub, ok := c.Locals("address").(string) // 认证阶段放进去的身份
		if !ok {
			return c.SendStatus(fiber.StatusUnauthorized)
		}
		allowed, err := e.Enforce(sub, obj, act)
		if err != nil {
			return c.SendStatus(fiber.StatusInternalServerError)
		}
		if !allowed {
			return c.SendStatus(fiber.StatusForbidden) // 403,不是 401
		}
		return c.Next()
	}
}

用的时候:app.Delete("/api/data/:id", Authorize(enforcer, "data", "delete"), handler)。

这里正好把认证与授权串起来:认证中间件产出身份(c.Locals("address")),授权中间件消费这个身份去问 Casbin「能不能删」。403 vs 401 的区别也是这个道理——401 是「没认证/认证失败」,403 是「认证过了但没权限」。

几个要点与坑

  1. g 是自反的:g(reader, reader) 为真。所以 sub 传角色名也能匹配到策略。别把用户输入直接当 sub。
  2. Enforce 的参数必须和 request_definition 严格一一对应(个数、顺序、类型)。r = sub, obj, act 就必须传三个,漏一个直接报错或结果错乱。
  3. policy_effect 决定默认行为。some(where (p.eft == allow)) 是「无命中即拒绝」;如果业务需要「黑名单优先」,得用 !some(where (p.eft == deny)) 的写法。
  4. 角色继承用 g 的嵌套即可,如 g, admin, editor 表示 admin 继承 editor。不必为继承另建模型。
  5. NewEnforcer 有加载开销,不要每个请求都新建;生产里应做成单例,策略改动用 watcher / LoadPolicy() 热更新,而不是重启服务。
  6. stub:RBAC 不是越细越好。权限粒度太细(细到每个接口一个权限)会让策略表膨胀到没人看得懂;通常按「资源 × 动作」这一层就够,再上面的复杂度应该靠角色继承消化。
  7. 认证与授权不要混。RBAC 只回答「你能干什么」,它不能证明你是谁——签名校验、token 验签该做还得做,顺序是「先认证拿身份,再授权查权限」。

小结

  • 认证管「你是谁」,授权管「你能干什么」,RBAC 是授权的一种模型。
  • RBAC 的核心:用户 → 角色 → 权限,中间那层角色让权限可批量、可审计、可复用。
  • Casbin 把 RBAC 拆成 model(怎么判定)+ policy(谁有什么)+ request(这次行不行),实现「逻辑与数据分离」。
  • 模型的核心就是那行 m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act:主体靠 g 走角色(含继承),对象与动作精确匹配。

初始化区块链连接

建立连接

用 Go 初始化以太坊客户端是和区块链交互所需的基本步骤。

首先,导入 go-ethereum 的 ethclient 包并通过调用接收区块链服务提供者 URL的Dial 来初始化它。

package main

/**
There are several Ethereum client getting ways
1. local server
client, err := ethclient.Dial("http://localhost:8545")
OR
client, err := ethclient.Dial("/home/user/.ethereum/geth.ipc")
2. RPC
client, err := ethclient.Dial("https://mainnet.infura.io")
**/

import (
	"github.com/ethereum/go-ethereum/ethclient"
	"log"
)

var (
	client *ethclient.Client
	err    error
)

func init() {
	client, err = ethclient.Dial("url")
	if err != nil {
		log.Fatal(err)
	}
}

区块链Id

区块链 peer-to-peer 网络结构下,全部节点基于 networkID 建立连接,但是发送链交易的时候,使用的 chainID 防止交易的重放攻击

	networkID, err := client.NetworkID(ctx)
	chainId, err := client.ChainID(ctx)

完整代码:

package milestone1

import (
	"context"
	"errors"
	"fmt"
	"github.com/ethereum/go-ethereum/ethclient"
	"log"
	"main/config"
)

var (
	client *ethclient.Client
	err    error
)

func init() {
	client = config.NewClient(config.SymbolETH)
	if client == nil {
		checkError(errors.New(fmt.Sprintf("Error in building new client err = %v", err)))
	}
}

func checkError(err error) {
	if err != nil {
		log.Fatalf("Error = %v", err)
	}
}

// NetworkId returns the network ID for this client.
func NetworkId(ctx context.Context) (string, error) {
	networkID, err := client.NetworkID(ctx)
	if err != nil {
		checkError(errors.New(fmt.Sprintf("Error in get networkID, err = %v", err)))
	}
	return networkID.String(), nil
}

func ChainId(ctx context.Context) (string, error) {
	chainId, err := client.ChainID(ctx)
	if err != nil {
		checkError(errors.New(fmt.Sprintf("Error in get chainID, err = %v", err)))
	}
	return chainId.String(), nil
}

账户信息

读取账户余额

eth_getBalance 支持读取传参地址在特定区块高度、特定区块hash、最新区块中、Pending池的余额

读取最新区块高度的账户余额

	balance, err := client.BalanceAt(ctx, account, nil) //nil is the latest block

读取特定区块中账户余额-BlockHeight

	blockNum := big.NewInt(99999)
	balance, err := client.BalanceAt(ctx, account, blockNum)

区块 num 的相关异常情况:

func toBlockNumArg(number *big.Int) string {
	if number == nil {
		return "latest"
	}
// Sign returns:
//   - -1 if x < 0;
//   - 0 if x == 0;
//   - +1 if x > 0.
	if number.Sign() >= 0 {
		return hexutil.EncodeBig(number)
	}
	// It's negative.
	// IsInt64 reports whether x can be represented as an int64.
	if number.IsInt64() {
		return rpc.BlockNumber(number.Int64()).String()
	}
	// It's negative and large, which is invalid.
	return fmt.Sprintf("<invalid %d>", number)
}
  • blockNum == nil, 表示基于最新区块高度的合约状态读取 slot 数值
  • blockNum 为负数:
    • 数值 -1 ~-4 都有具体类型的对应
    • 数值 <-4,报错 invalid argument 1: hex string without 0x prefix
func (bn BlockNumber) String() string {
	switch bn {
	case EarliestBlockNumber: //0
		return "earliest"
	case LatestBlockNumber://-2
		return "latest"
	case PendingBlockNumber://-1
		return "pending"
	case FinalizedBlockNumber://-3
		return "finalized"
	case SafeBlockNumber://-4
		return "safe"
	default:
		if bn < 0 {
			return fmt.Sprintf("<invalid %d>", bn)
		}
		return hexutil.Uint64(bn).String()
	}
}
  • blockNum 为正数: 读取截止当前区块高度时的账户余额
  • 提供的区块高度 > latestBlockHeight,报错 ethereum.NotFound

读取特定区块中账户余额-BlockHash

	blockHash := common.HexToHash("0x0fa8fe23357be11db6273d5744a091b7f5baa70d7824addd680c8ed1fd2fbf0b")
	balance, err := client.BalanceAtHash(ctx, account, blockHash)

读取 Pending 池中账户余额

账户提交的待处理交易进入 pending 池等待校验打包

  • 账户的余额随着构建交易会付出 gas 或者转账
  • 在 pending 此种获取账户最新余额,有可能比直接读取余额的值要小
    • 取决于 RPC 的速度,如果 RPC 在网络中已经接收到了该交易,则返回执行该交易后的剩余余额,否则返回正常余额
    balance, err := client.PendingBalanceAt(ctx, account)

以太坊中的数字是使用尽可能小的单位来处理的,因为它们是定点精度,在 ETH 中它是 wei。

要读取 ETH 值,您必须做计算 wei/10^18

WEI/Eth converter

func calcuBalanceToEth(bal *big.Int) *big.Float {
	fbalance := new(big.Float)
	fbalance.SetString(bal.String())
	fbalance = fbalance.Quo(fbalance, big.NewFloat(math.Pow10(18)))
	return fbalance
}

完整代码

package milestone1

import (
	"context"
	"errors"
	"fmt"
	"github.com/ethereum/go-ethereum/common"
	"math"
	"math/big"
)

func BalanceFromLatestBlock(account common.Address, ctx context.Context) (*big.Int, error) {
	balance, err := client.BalanceAt(ctx, account, nil) //nil is the latest block
	if err != nil {
		checkError(errors.New(fmt.Sprintf("Error in get account balance err = %v", err)))
	}
	return balance, nil
}

func BalanceFromBlock(account common.Address, number *big.Int, ctx context.Context) (*big.Int, error) {
	balance, err := client.BalanceAt(ctx, account, number)
	if err != nil {
		checkError(errors.New(fmt.Sprintf("Error in get account balance err = %v", err)))
	}
	return balance, nil
}

func BalanceFromBlockHash(account common.Address, hash common.Hash, ctx context.Context) (*big.Int, error) {
	balance, err := client.BalanceAtHash(ctx, account, hash)
	if err != nil {
		checkError(errors.New(fmt.Sprintf("Error in get account balance err = %v", err)))
	}
	return balance, nil
}

func BalanceFromPendingPool(account common.Address, ctx context.Context) (*big.Int, error) {
	balance, err := client.PendingBalanceAt(ctx, account)
	if err != nil {
		checkError(errors.New(fmt.Sprintf("Error in get account balance err = %v", err)))
	}
	return balance, nil
}

func calcuBalanceToEth(bal *big.Int, decimal int) *big.Float {
	balance := new(big.Float)
	balance.SetString(bal.String())
	balance = balance.Quo(balance, big.NewFloat(math.Pow10(decimal)))
	return balance
}

账户信息

读取账户数据

  • EOA:从私钥导出的账户地址,允许直接构建交易操作账户余额
  • Smart Contract:EOA 创建的合约地址,账户下存储特定的合约逻辑
    • 合约账户不允许直接构造交易
    • EIP4337介绍如何实现合约账户的交易代付
  • 合约地址和EOA地址的区别在于合约地址在账户中存储合约代码,通过判断地址数据长度可以判断当前地址是否是合约地址
  • eth_getCode 支持读取传参地址在特定区块高度、特定区块 hash、最新区块中、Pending 池中的账户 contract codes

读取最新区块高度的账户contract codes

将区块号设置为 nil 将使用最新的区块高度

  1. 先使用简单的正则表达式来检查以太坊地址是否有效
  2. 获取地址存储的代码
    1. 如果长度为空,表示目前该地址是 EOA 地址
    2. 如果长度不为空,表明该地址是合约地址
  3. Solidity判断
	bytecode, err := client.CodeAt(context.Background(), addr, nil) //nil is the latest block

读取特定区块中的账户codes-BlockHeight

	blockNum := big.NewInt(99999)
	bytecode, err := client.CodeAt(context.Background(), addr, blockNum)

读取特定区块中的账户codes-BlockHash

	blockHash := common.HexToHash("0x0fa8fe23357be11db6273d5744a091b7f5baa70d7824addd680c8ed1fd2fbf0b")
	bytecode, err := client.CodeAtHash(ctx, addr, blockHash)

获取账户在 pending 池中的codes

合约部署交易已经提交,但是还没有得到确定

此时,如果 RPC 收到该交易,验证执行后会返回该地址的 codes

    bytecode, err := client.PendingCodeAt(ctx, account)

完整代码

package milestone1

import (
	"context"
	"errors"
	"fmt"
	"github.com/ethereum/go-ethereum/common"
	"math/big"
	"regexp"
)

// check the address whether it is a valid  address
func validAddress(addr common.Address) bool {
	// 16 hex 0-f
	re := regexp.MustCompile("^0x[0-9a-fA-F]{40}$")
	return re.MatchString(addr.Hex())
}

// check the address whether is a smart contract address
func checkContractAddressInLatestBlock(addr common.Address, ctx context.Context) bool {
	if !validAddress(addr) {
		return false
	}
	bytecode, err := client.CodeAt(ctx, addr, nil) //nil is the latest block
	if err != nil {
		checkError(errors.New(fmt.Sprintf("Error in get account codes, error = %v", err)))
	}
	isContract := len(bytecode) > 0
	if isContract {
		return true
	}
	//fmt.Println("This is normal address, but we want a smart contract address")
	return false
}

func checkContractAddressInBlock(addr common.Address, number *big.Int, ctx context.Context) bool {
	if !validAddress(addr) {
		return false
	}
	bytecode, err := client.CodeAt(ctx, addr, number)
	if err != nil {
		checkError(errors.New(fmt.Sprintf("Error in get account codes, error = %v", err)))
	}
	isContract := len(bytecode) > 0
	if isContract {
		return true
	}
	//fmt.Println("This is normal address, but we want a smart contract address")
	return false
}

func checkContractAddressInBlockHash(addr common.Address, hash common.Hash, ctx context.Context) bool {
	if !validAddress(addr) {
		return false
	}
	bytecode, err := client.CodeAtHash(ctx, addr, hash)
	if err != nil {
		checkError(errors.New(fmt.Sprintf("Error in get account codes, error = %v", err)))
	}
	isContract := len(bytecode) > 0
	if isContract {
		return true
	}
	//fmt.Println("This is normal address, but we want a smart contract address")
	return false
}

func checkContractAddressInPendingPool(addr common.Address, ctx context.Context) bool {
	if !validAddress(addr) {
		return false
	}
	bytecode, err := client.PendingCodeAt(ctx, addr)
	if err != nil {
		checkError(errors.New(fmt.Sprintf("Error in get account codes, error = %v", err)))
	}
	isContract := len(bytecode) > 0
	if isContract {
		return true
	}
	//fmt.Println("This is normal address, but we want a smart contract address")
	return false
}

New account

ECDSA 生成新的地址

要首先生成一个新的钱包,需要导入 go-ethereum crypto 包,该包提供用于生成随机私钥的 GenerateKey 方法

  1. 生成全新的私钥,并将私钥存储在本地文件
  2. 基于私钥导出公钥信息
  3. 基于公钥导出账户地址
package main

import (
	"crypto/ecdsa"
	"github.com/ethereum/go-ethereum/common"
	"github.com/ethereum/go-ethereum/crypto"
	"log"
	"strings"
)

func NewAccount() common.Address {
	ecdsaPrivateKey, err := crypto.GenerateKey()
	if err != nil {
		log.Fatal(err)
	}
	return EcdsaAddressFromPrivateKey(ecdsaPrivateKey)
}

func AddressFromKey(key string) common.Address {
	key = strings.TrimPrefix(key, "0x")
	ecdsaPrivateKey, err := crypto.HexToECDSA(key)
	if err != nil {
		log.Fatal(err)
	}
	rAddress := EcdsaAddressFromPrivateKey(ecdsaPrivateKey)
	return rAddress
}

func EcdsaAddressFromPrivateKey(ecdsaPrivateKey *ecdsa.PrivateKey) common.Address {
	publicKeyBytes := crypto.FromECDSAPub(ecdsaPrivateKey.Public().(*ecdsa.PublicKey))
	pub, err := crypto.UnmarshalPubkey(publicKeyBytes)
	if err != nil {
		log.Fatal(err)
	}
	rAddress := crypto.PubkeyToAddress(*pub)
	return rAddress
}

生成助记词,从助记词导出新地址

地址生成方式:

  1. 生成助记词
  2. 拼接助记词和 secret, 构造哈希种子 seed
  3. 基于 seed 逐步生成 ecdsa 私钥
  4. 基于私钥导出钱包地址

导出账户密钥

  1. 基于助记词导出密钥时:
  • 必须提供相匹配的 secret 值
  • 拼接出生成账户用的哈希种子 seed
  • 基于 seed 产生相同的私钥和地址
  • 因此,secret 用于进一步保证助记词的安全
func main() {
    secret := "ert"
    mnemonic, address := KeyFromMnemonic(secret)
    fmt.Println(mnemonic, address)
    KeyFromMnemonicInput(mnemonic, secret)
}
func KeyFromMnemonic(secret string) (string, common.Address) {
	// Generate a mnemonic
	entropy, _ := bip39.NewEntropy(256)
	mnemonic, _ := bip39.NewMnemonic(entropy)
	// Generate a Bip32 HD wallet for the mnemonic and a user supplied passphrase
	seed := bip39.NewSeed(mnemonic, secret)
	masterPrivateKey, _ := bip32.NewMasterKey(seed)
	ecdsaPrivateKey := crypto.ToECDSAUnsafe(masterPrivateKey.Key)
	address := EcdsaAddressFromPrivateKey(ecdsaPrivateKey)
	return mnemonic, address
}

func KeyFromMnemonicInput(mnemonic, secret string) string {
	seed := bip39.NewSeed(mnemonic, secret)
	masterPrivateKey, _ := bip32.NewMasterKey(seed)
	ecdsaPrivateKey := crypto.ToECDSAUnsafe(masterPrivateKey.Key)
	privateKeyHex := fmt.Sprintf("%x", ecdsaPrivateKey.D)
	address := EcdsaAddressFromPrivateKey(ecdsaPrivateKey)
	fmt.Println(address)
	return privateKeyHex
}

从Keystore导出地址

  1. 创建新的钱包地址,账户信息通过 secret 加密存储并导出 keystore 文件
  2. 基于 secret 解密 keystore 文件

私钥签名

  1. keystore 直接基于 secret 签名数据
  2. keystore 可以先基于 secret TimedUnlock 解锁一段时间后可以直接用于签名
import (
	"github.com/ethereum/go-ethereum/accounts/keystore"
	"github.com/ethereum/go-ethereum/common"
	"github.com/ethereum/go-ethereum/common/hexutil"
	"log"
	"os"
)

func NewKeystoreAccount(secret string) common.Address {
	ks := keystore.NewKeyStore("./wallets", keystore.StandardScryptN, keystore.StandardScryptP)
	account, err := ks.NewAccount(secret)
	if err != nil {
		log.Fatal(err)
	}
	return account.Address
}

func AddressFromKeystore(file, secret string) common.Address {
	ks := keystore.NewKeyStore("./tmp", keystore.StandardScryptN, keystore.StandardScryptP)
	jsonBytes, err := os.ReadFile(file)
	if err != nil {
		log.Fatal(err)
	}
	account, err := ks.Import(jsonBytes, secret, secret)
	if err != nil {
		log.Fatal(err)
	}
	return account.Address
}

func SignatureFromKeystoreAfterUnlock(file, secret string) string {
    ks := keystore.NewKeyStore("./tmp", keystore.StandardScryptN, keystore.StandardScryptP)
    jsonBytes, err := os.ReadFile(file)
    if err != nil {
        checkError(errors.New(fmt.Sprintf("Error in reading keystore, error = %v", err)))
    }
    account, err := ks.Import(jsonBytes, secret, secret)
    if err != nil {
        checkError(errors.New(fmt.Sprintf("Error in exporting keystore account, error = %v", err)))
    }
    err = ks.Unlock(account, secret)
    if err != nil {
        checkError(errors.New(fmt.Sprintf("Error in unlocking keystore account, error = %v", err)))
	}
    signatureBytes, err := ks.SignHash(account, []byte("ww"))
    return hexutil.Encode(signatureBytes)
}

func SignatureFromKeystore(file, secret string) string {
	ks := keystore.NewKeyStore("./tmp", keystore.StandardScryptN, keystore.StandardScryptP)
	jsonBytes, err := os.ReadFile(file)
	if err != nil {
		log.Fatal(err)
	}
	account, err := ks.Import(jsonBytes, secret, secret)
	if err != nil {
		log.Fatal(err)
	}
	signatureBytes, err := ks.SignHashWithPassphrase(account, secret, []byte("ww"))
	return hexutil.Encode(signatureBytes)
}

GetBlocks

区块Header结构

type Bloom [256]byte
type BlockNonce [8]byte

// Header represents a block header in the Ethereum blockchain.
type Header struct {
	ParentHash  common.Hash    `json:"parentHash"       gencodec:"required"`
	UncleHash   common.Hash    `json:"sha3Uncles"       gencodec:"required"`
	Coinbase    common.Address `json:"miner"`
	Root        common.Hash    `json:"stateRoot"        gencodec:"required"`
	TxHash      common.Hash    `json:"transactionsRoot" gencodec:"required"`
	ReceiptHash common.Hash    `json:"receiptsRoot"     gencodec:"required"`
	Bloom       Bloom          `json:"logsBloom"        gencodec:"required"`
	Difficulty  *big.Int       `json:"difficulty"       gencodec:"required"`
	Number      *big.Int       `json:"number"           gencodec:"required"`
	GasLimit    uint64         `json:"gasLimit"         gencodec:"required"`
	GasUsed     uint64         `json:"gasUsed"          gencodec:"required"`
	Time        uint64         `json:"timestamp"        gencodec:"required"`
	Extra       []byte         `json:"extraData"        gencodec:"required"`
	MixDigest   common.Hash    `json:"mixHash"`
	Nonce       BlockNonce     `json:"nonce"`

	// BaseFee was added by EIP-1559 and is ignored in legacy headers.
	BaseFee *big.Int `json:"baseFeePerGas" rlp:"optional"`

	// WithdrawalsHash was added by EIP-4895 and is ignored in legacy headers.
	WithdrawalsHash *common.Hash `json:"withdrawalsRoot" rlp:"optional"`

	// BlobGasUsed was added by EIP-4844 and is ignored in legacy headers.
	BlobGasUsed *uint64 `json:"blobGasUsed" rlp:"optional"`

	// ExcessBlobGas was added by EIP-4844 and is ignored in legacy headers.
	ExcessBlobGas *uint64 `json:"excessBlobGas" rlp:"optional"`

	// ParentBeaconRoot was added by EIP-4788 and is ignored in legacy headers.
	ParentBeaconRoot *common.Hash `json:"parentBeaconBlockRoot" rlp:"optional"`

	// RequestsHash was added by EIP-7685 and is ignored in legacy headers.
	RequestsHash *common.Hash `json:"requestsRoot" rlp:"optional"`
}

返回特定区块的区块头信息-BlockHash

返回最新区块的区块头信息,不包含具体的交易区块体

package main

import (
	"context"
	"fmt"
	"github.com/ethereum/go-ethereum/common"
	"github.com/ethereum/go-ethereum/core/types"
	"github.com/ethereum/go-ethereum/ethclient"
	"log"
	"math/big"
)

var (
	client *ethclient.Client
	err    error
)

func init() {
	client, err = ethclient.Dial("https://eth.llamarpc.com")
	if err != nil {
		log.Fatal(err)
	}
}
func checkError(err error) {
	if err != nil {
		log.Fatal(err)
	}
}

func main() {
	var ctx = context.Background()
	var blockhash = "0x6d7977fbf9333267c5bc25b596eacc2ef89461289e078dd7283c6872008646bc"
	headers, err := getBlockHeader(ctx, common.HexToHash(blockhash))
	checkError(err)
	fmt.Println(fmt.Sprintf("Latest block header info = %s", headers))
	var header types.Header
	if err = header.UnmarshalJSON([]byte(headers)); err != nil {
		log.Fatalf("decode error: %v ", err)
	}
	fmt.Println(header.Number)
}

func getBlockHeader(ctx context.Context, hash common.Hash) (string, error) {
	header, err := client.HeaderByHash(ctx, hash)
	if err != nil {
		if errors.Is(err, ethereum.NotFound) {
			return "", errors.New("non-exist blockHash")
		}else{
			checkError(err)
        }
	}
	headerBytes, err := header.MarshalJSON()
	if err != nil {
		return string(headerBytes), err
	}
	return string(headerBytes), err
}

Examples:

{
   "parentHash":"0x220987cbab8e6bc276671b33b8d6f1207dab1fc80ffe58165b1b070d34f73fc7",
   "sha3Uncles":"0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347",
   "miner":"0x1f9090aae28b8a3dceadf281b0f12828e676c326",
   "stateRoot":"0x2e4cfaff4b5f165bd7178091f6b13ff74e5492278fdc1ff1efbe9866347cedc2",
   "transactionsRoot":"0x0558cbcf91c9de46e950988836c0e305f335292a1f821cff5505c12e79e5ada9",
   "receiptsRoot":"0x98b457f5ac24528e16d6c26107ac3f210f4d820bbfe587e8df5562457f950683",
   "logsBloom":"0x0000004000000004000010000200000030000100000000001000000000000000000000000000000000000000200001002200000008002000028000000000000000000008010000080000020800000000000000900000000000000014002000000008000000200220000000000000000002000000000400000000001000080000002000000a0000000000000000200000000080000000000000000000001000002500000000000000000000a0000000800000040000000000000000000000000001000022000808000000080000400800000000000000000000000000000020020200282000040000000000000000000000201000000000800000000000000200",
   "difficulty":"0x0",
   "number":"0x1447225",
   "gasLimit":"0x1c9c380",
   "gasUsed":"0xe16bf",
   "timestamp":"0x6744146f",
   "extraData":"0x7273796e632d6275696c6465722e78797a",
   "mixHash":"0x1f49ed8baffafa6b5fd66b1f2577f47eb508a6d0c5444c28b588b5e76a9a40c2",
   "nonce":"0x0000000000000000",
   "baseFeePerGas":"0x1ba8af27d",
   "withdrawalsRoot":"0x80ecd553069724318de66efd3099be1f97f62df4ff3fb94c30f16c3aee275f62",
   "blobGasUsed":"0x0",
   "excessBlobGas":"0x4b20000",
   "parentBeaconBlockRoot":"0xffd644329d46413352298de6bde7ca71752862a2bae34f0e3ead6970a3e9912a",
   "requestsRoot":null,
   "hash":"0x6d7977fbf9333267c5bc25b596eacc2ef89461289e078dd7283c6872008646bc"
}

返回特定区块的区块头信息-BlockHeight

func checkError(err error) {
	if err != nil {
		log.Fatalf("error = %v", err)
	}
}
func main() {
	var ctx = context.Background()
	headers, err := getTargetBlockHeader(ctx, big.NewInt(-2))//nil is the latest block height
	if err != nil {
		if errors.Is(err, ethereum.NotFound) {
			fmt.Println("invalid block height")
		} else {
			checkError(err)
		}
	} else {
		headerBytes, err := headers.MarshalJSON()
		if err == nil {
			fmt.Println(fmt.Sprintf("Target block header info = %s", string(headerBytes)))
			var header types.Header
			if err = header.UnmarshalJSON([]byte(string(headerBytes))); err != nil {
				log.Fatalf("decode error: %v ", err)
			}
			fmt.Println(header.Number)
		} else {
			checkError(err)
		}
	}
}

func getTargetBlockHeader(ctx context.Context, number *big.Int) (*types.Header, error) {
	header, err := client.HeaderByNumber(ctx, number)
	return header, err
}

BlocksDataEncode

rlp 编码区块信息

func main() {
	var ctx = context.Background()
	hash := "0x6d7977fbf9333267c5bc25b596eacc2ef89461289e078dd7283c6872008646bc"
	blockInfo := getBlock(ctx, common.HexToHash(hash))
	fmt.Println(fmt.Printf("Blocks data %s  within block hash = %s", blockInfo, hash))
	txs := decodeBlock(blockInfo)
	fmt.Println(txs)
}

func getBlock(ctx context.Context, hash common.Hash) string {
	block, err := client.BlockByHash(ctx, hash)
    if err != nil {
        if errors.Is(err, ethereum.NotFound) {
            return "", errors.New("non-exist blockHash")
        }else{
            checkError(err)
        }
    }
	BlockEnc, err := rlp.EncodeToBytes(&block)
	if err != nil {
		log.Fatalf("Encode blocks err = %v", err)
	}
	blockStr := common.Bytes2Hex(BlockEnc)
	return blockStr
}
func decodeBlock(blockInfo string) []string {
	blockEnc := common.FromHex(blockInfo)
	var block types.Block
	if err := rlp.DecodeBytes(blockEnc, &block); err != nil {
		log.Fatalf("decode error: %v", err)
	}
	tx := make([]string, len(block.Body().Transactions))
	for i, trans := range block.Body().Transactions {
		transB, _ := trans.MarshalJSON()
		tx[i] = string(transB)
	}
	return tx
}

BlocksReceipt

获取当前区块体中全部交易的收据信息,用于判断每条交易的执行状态

func checkError(err error) {
	if err != nil {
		log.Fatalf("error = %v", err)
	}
}

func main() {
	var ctx = context.Background()
	hash := "0x6d7977fbf9333267c5bc25b596eacc2ef89461289e078dd7283c6872008646bc"
	var filter rpc.BlockNumberOrHash = rpc.BlockNumberOrHashWithHash(common.HexToHash(hash), false)
	count := getBlockReceipt(ctx, filter)
	fmt.Println(count)
}

func getBlockReceipt(ctx context.Context, filter rpc.BlockNumberOrHash) int {
	receipts, err := client.BlockReceipts(ctx, filter)
	if err != nil {
		if errors.Is(err, ethereum.NotFound) {
			fmt.Println("invalid block height")
		} else {
			checkError(err)
		}
	}
	return len(receipts)
}

GetTransactions

根据区块hash 查询区块体中交易总数量

传参区块哈希值,查询该区块体中的交易数量

  1. 传参无效的 hash 值
    1. 无法找到有效的区块,因此在解析数据时会报错
    2. 报错json: cannot unmarshal non-string into Go value of type hexutil.Uint
func main() {
	var ctx = context.Background()
	hash := "0x6d7977fbf9333267c5bc25b596eacc2ef89461289e078dd7283c6872008646bc"
	count := getTxCountBlockHeader(ctx, common.HexToHash(hash))
	fmt.Println(count)
}

func getTxCountBlockHeader(ctx context.Context, hash common.Hash) uint {
	count, err := client.TransactionCount(ctx, hash)
	if err != nil {
		if errors.Is(err, ethereum.NotFound) {
			fmt.Println("invalid block height")
		} else {
			checkError(err)
		}
	}
	return count
}

根据交易hash获取交易信息

每笔交易在钱包处构建并产生区块 hash,但是可能存在多种原因导致该交易并未提交到链上处理

此时,对于区块链上来说,这笔交易并不存在。

因此,执行交易会报错 ethereum.NotFound

func main() {
	var txHash = "0x8742cd8c26d22fb7e7c38eb63c0cae5afc35aaff6bf768a802b57ac675240822"
	GetTxByhash(txHash)
}

func GetTxByhash(hash string) {
	tx, pending, err := client.TransactionByHash(context.Background(), common.HexToHash(hash))
	if err != nil {
		if errors.Is(err, ethereum.NotFound) {
			// todo
			return
		} else {
			// todo
			return
		}
	}
	if !pending {
		signer := types.LatestSignerForChainID(tx.ChainId())
		sender, err := signer.Sender(tx)
		if err != nil {
			fmt.Printf("Error in rebuilding transactions's sender: %s", err)
			return
		}
		nonce, err := client.NonceAt(context.Background(), sender, nil)
		if err != nil {
			fmt.Printf("Error in getting transactions's sender's nonce: %s", err)
			return
		}
		pendingNonce, err := client.PendingNonceAt(context.Background(), sender)
		if err != nil {
			fmt.Printf("Error in rebuilding transactions's sender's pending nonce: %s", err)
			return
		}
		fmt.Printf("txHash: %s, sender = %s, isPending: %v, nonce: %d, pendingNocne: %d", hash, sender, pending, nonce, pendingNonce)
	}
}

根据hash获取交易收据

  1. 先获取交易数据,判断当前交易是否存在以及当前交易是否仍然处在 pending 状态
  2. 只有当前交易 hash 有效以及被打包出块的情况下,才能有效的获取该交易 hash 的收据状态
    1. 收据树中记录该交易执行完毕触发的全部 logs 信息
    2. 收据树记录当前交易的执行状态,Failed(0),Success(1)
	if !pending {
		receipt, err := client.TransactionReceipt(ctx, hash)
		if err != nil {
			fmt.Printf("Error in getting transactions's receipt: %s", err)
			return
		}
		if receipt.Status == 0 {
			fmt.Printf("transactions failed")
		} else {
			txReceiptB, _ := receipt.MarshalJSON()
			fmt.Printf("transactions success with receiprt: %s", string(txReceiptB))
		}
	}

Examples:

{
   "type":"0x2",
   "root":"0x",
   "status":"0x1",
   "cumulativeGasUsed":"0x127522",
   "logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010002000000080020000080000000000000000000000000000800000008000000000000001000000000000000040000000000000000002000000000000000000000000000000000000000000010000800000000000000000000000000000000000000000000000000000000000000100000040000000000000000000080000000000000000000000000000000000000000000000002000008000000000000000800000000000000000000000000000000020000202000000000000000000000000000000000000000000000000000000000",
   "logs":[
      {
         "address":"0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2",
         "topics":[
            "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
            "0x000000000000000000000000c7bbec68d12a0d1830360f8ec58fa599ba1b0e9b",
            "0x0000000000000000000000001f2f10d1c40777ae1da742455c65828ff36df387"
         ],
         "data":"0x000000000000000000000000000000000000000000000001657e41135b73353a",
         "blockNumber":"0x144739e",
         "transactionHash":"0x8742cd8c26d22fb7e7c38eb63c0cae5afc35aaff6bf768a802b57ac675240822",
         "transactionIndex":"0x3",
         "blockHash":"0x4bb79e27c629d2014159dabb7ca612cbda517b73c651ef8d5aa89a6efbb3736e",
         "logIndex":"0x21",
         "removed":false
      },
      {
         "address":"0xdac17f958d2ee523a2206206994597c13d831ec7",
         "topics":[
            "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
            "0x0000000000000000000000001f2f10d1c40777ae1da742455c65828ff36df387",
            "0x000000000000000000000000c7bbec68d12a0d1830360f8ec58fa599ba1b0e9b"
         ],
         "data":"0x000000000000000000000000000000000000000000000000000000142dbe2e00",
         "blockNumber":"0x144739e",
         "transactionHash":"0x8742cd8c26d22fb7e7c38eb63c0cae5afc35aaff6bf768a802b57ac675240822",
         "transactionIndex":"0x3",
         "blockHash":"0x4bb79e27c629d2014159dabb7ca612cbda517b73c651ef8d5aa89a6efbb3736e",
         "logIndex":"0x22",
         "removed":false
      },
      {
         "address":"0xc7bbec68d12a0d1830360f8ec58fa599ba1b0e9b",
         "topics":[
            "0xc42079f94a6350d7e6235f29174924f928cc2ac818eb64fed8004e115fbcca67",
            "0x0000000000000000000000001f2f10d1c40777ae1da742455c65828ff36df387",
            "0x0000000000000000000000001f2f10d1c40777ae1da742455c65828ff36df387"
         ],
         "data":"0xfffffffffffffffffffffffffffffffffffffffffffffffe9a81beeca48ccac6000000000000000000000000000000000000000000000000000000142dbe2e0000000000000000000000000000000000000000000003ce3f9e66833b15f71c2c00000000000000000000000000000000000000000000000008a38c3903c19846fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffd05f0",
         "blockNumber":"0x144739e",
         "transactionHash":"0x8742cd8c26d22fb7e7c38eb63c0cae5afc35aaff6bf768a802b57ac675240822",
         "transactionIndex":"0x3",
         "blockHash":"0x4bb79e27c629d2014159dabb7ca612cbda517b73c651ef8d5aa89a6efbb3736e",
         "logIndex":"0x23",
         "removed":false
      }
   ],
   "transactionHash":"0x8742cd8c26d22fb7e7c38eb63c0cae5afc35aaff6bf768a802b57ac675240822",
   "contractAddress":"0x0000000000000000000000000000000000000000",
   "gasUsed":"0x36f17",
   "effectiveGasPrice":"0xc4f0d4dc4",
   "blockHash":"0x4bb79e27c629d2014159dabb7ca612cbda517b73c651ef8d5aa89a6efbb3736e",
   "blockNumber":"0x144739e",
   "transactionIndex":"0x3"
}

根据区块hash查询区块体中交易

遍历区块体中的交易数据:

func main() {
	var ctx = context.Background()
	hash := "0x6d7977fbf9333267c5bc25b596eacc2ef89461289e078dd7283c6872008646bc"
	block, err := client.BlockByHash(ctx, common.HexToHash(hash))
	if err != nil {
		if errors.Is(err, ethereum.NotFound) {
			fmt.Println("non-exist blockHash")
			return
		} else {
			checkError(err)
		}
	}
	tx := make([]string, len(block.Body().Transactions))
	for i, trans := range block.Body().Transactions {
		transB, _ := trans.MarshalJSON()
		tx[i] = string(transB)
	}
	fmt.Println(fmt.Printf("Transaction details :%v, within blockhash = %s", tx, hash))
}

根据交易索引获取交易信息

区块体中的交易是有序的,保证全部验证者执行顺序的一致,从而有效校验区块

  1. 根据区块信息和交易顺序,能够有效获取当前交易
  2. 提供无效的区块hash时,报错ethereum.NotFound
  3. 提供无效的区块Index值时:
    1. 提供的值 < 0, 报错 constant -1 overflows uint
    2. 提供的值 > 最大值,报错 ethereum.NotFound
func main() {
	var ctx = context.Background()
	var blockHash = "0x6d7977fbf9333267c5bc25b596eacc2ef89461289e078dd7283c6872008646bc"
	GetTxByBlockHashAndIndex(ctx, common.HexToHash(blockHash), uint(3))
}

func GetTxByBlockHashAndIndex(ctx context.Context, hash common.Hash, index uint) {
	tx, err := client.TransactionInBlock(ctx, hash, index)
	if err != nil {
		if errors.Is(err, ethereum.NotFound) {
			fmt.Println(fmt.Printf("Invalid block hash = %s", hash))
			return
		} else {
			checkError(err)
		}
	}
	txB, _ := tx.MarshalJSON()
	fmt.Printf("transactions info= %s within blockHash = %s, blockIndex = %d\n", string(txB), hash, index)
	parseTx(string(txB))
}
func parseTx(tx string) {
	check := func(f string, got, want interface{}) {
		if !reflect.DeepEqual(got, want) {
			log.Fatalf("%s mismatch: got %v, want %v", f, got, want)
		}
	}
	var transaction types.Transaction
	err := transaction.UnmarshalJSON([]byte(tx))
	if err != nil {
		log.Fatalf("Unmarshal tx err = %v", err)
	}
	txB, _ := transaction.MarshalJSON()
	check("TxInfo", string(txB), tx)
}

Parse Smart Contract transactions

package main

import (
	"context"
	"errors"
	"fmt"
	"github.com/ethereum/go-ethereum"
	"github.com/ethereum/go-ethereum/accounts/abi"
	"github.com/ethereum/go-ethereum/common"
	"github.com/ethereum/go-ethereum/ethclient"
	"io"
	"log"
	"os"
	"strings"
)

var (
	client *ethclient.Client
	err    error
)

func init() {
	client, err = ethclient.Dial("https://eth.llamarpc.com")
	if err != nil {
		log.Fatal(err)
	}
}
func checkError(err error) {
	if err != nil {
		log.Fatalf("error = %v", err)
	}
}

func main() {
	var ctx = context.Background()
	var txHash = "0x7527da7c98477c6961fe7c3218255c8e355c41b0f41c24633b78faa1967ff526"
	data := getTxdata(ctx, txHash)
	path := "./examples.json"
	abiFilter := getABI(path)
	DecodeTransactionInputData(abiFilter, data)
}

func getTxdata(ctx context.Context, hash string) []byte {
	tx, pending, err := client.TransactionByHash(ctx, common.HexToHash(hash))
	if err != nil {
		if errors.Is(err, ethereum.NotFound) {
			fmt.Println(fmt.Printf("Invalid block hash = %s", hash))
			return []byte{}
		} else {
			checkError(err)
		}
	}
	if !pending {
		return tx.Data()

	}
	return []byte{}
}

func getABI(path string) string {
	abiFile, err := os.Open(path)
	if err != nil {
		log.Fatal(err)
	}
	defer abiFile.Close()

	result, err := io.ReadAll(abiFile)
	if err != nil {
		log.Fatal(err)
	}
	return string(result)
}

func DecodeTransactionInputData(jsondata string, data []byte) {
	// The first 4 bytes of the t represent the ID of the method in the ABI
	contractABI, err := abi.JSON(strings.NewReader(jsondata))
	if err != nil {
		log.Fatalf("parse abi err :%v", err)
	}

	methodSigData := data[:4]
	method, err := contractABI.MethodById(methodSigData)
	if err != nil {
		log.Fatal(err)
	}
	inputsSigData := data[4:]
	inputsMap := make(map[string]interface{})
	if err := method.Inputs.UnpackIntoMap(inputsMap, inputsSigData); err != nil {
		log.Fatal(err)
	}
	fmt.Printf("Method Name: %s\n", method.Name)
	fmt.Printf("Method inputs: %v\n", inputsMap)
}

//Method Name: deposit
//Method inputs: map[_verifierAddress:nillion1mn4ce97demxwcwe2nr5djlm3q32e2apch770vs]

subscribeNewHead

通过 RPC 节点从网络订阅新区块信息,基于 websocket 和 channel 实现。

RPC 节点服务作为服务商,在收到新区块时将数据写入通道

Golang 本地作为使用方,从通道中及时读取数据,防止写入阻塞

  • 如果不及时读取通道数据
    • 报错 subscribe new block error: websocket: close 1006 (abnormal closure): unexpected EOF
  • 通过 for select 循环读取通道数据
    • 订阅对象包含 error 通道,返回订阅过程的报错信息
    • 判断读取 header 通道数据,并进行处理
package main

import (
	"context"
	"errors"
	"fmt"
	"github.com/ethereum/go-ethereum/core/types"
	"github.com/ethereum/go-ethereum/ethclient"
	"log"
)

var (
	client *ethclient.Client
	err    error
)

func init() {
	client, err = ethclient.Dial("wss://eth.drpc.org")
	if err != nil {
		checkError(errors.New(fmt.Sprintf("subclient failed to dial: %v", err)))
	}
}
func checkError(err error) {
	if err != nil {
		log.Fatalf("error = %v", err)
	}
}

func main() {
	subscribeNewHead()
}

func subscribeNewHead() {
	headers := make(chan *types.Header)
	sub, err := client.SubscribeNewHead(context.Background(), headers)
	if err != nil {
		checkError(errors.New(fmt.Sprintf("subclient failed to subscribe new block headers: %v", err)))

	}
	for {
		select {
		case err := <-sub.Err():
			log.Printf("subscribe new block error: %v", err)
			subscribeNewHead()
		case header := <-headers:
			fmt.Print(fmt.Sprintf("Receive new blocks hash = %s\n", header.Hash().Hex()))
			// block, _ := client.BlockByNumber(context.Background(), header.Number)
			// for _, tx := range block.Transactions() {
			// 	msg := tx.To()
			// }
		}
	}
}

subscribePendingTx

geth 提供查询 pending 交易的函数

  • subscribeFullPendingTransactions 用于接收完整的待处理交易
  • subscribePendingTransactions 用于接收待处理交易 hash
package main

import (
	"context"
	"errors"
	"fmt"
	"github.com/ethereum/go-ethereum/common"
	"github.com/ethereum/go-ethereum/core/types"
	"github.com/ethereum/go-ethereum/ethclient/gethclient"
	"github.com/ethereum/go-ethereum/rpc"
	"log"
	"sync"
)

var (
	subgclient *gethclient.Client
	client     *rpc.Client
	err        error
)

func init() {
	client, err = rpc.Dial("wss://eth.drpc.org")
	if err != nil {
		checkError(errors.New(fmt.Sprintf("subclient failed to dial: %v", err)))
	}
	subgclient = gethclient.New(client)
}
func checkError(err error) {
	if err != nil {
		log.Fatalf("error = %v", err)
	}
}

var (
	wg     sync.WaitGroup
	Tx     = "Tx"
	TxHash = "TxHash"
)

func main() {
	commands := []string{Tx, TxHash}
	lock := len(commands)
	wg.Add(lock)
	DoSubscribe(commands)
	wg.Wait()
}

func DoSubscribe(strs []string) {
	for _, str := range strs {
		switch str {
		case Tx:
			go subscribeFullPendingTx()
		case TxHash:
			go subscribePendingTxHash()
		}
	}
}

func subscribeFullPendingTx() {
	defer wg.Done()
	pendingTx := make(chan *types.Transaction)
	sub, err := subgclient.SubscribeFullPendingTransactions(context.Background(), pendingTx)
	if err != nil {
		checkError(errors.New(fmt.Sprintf("subclient failed to subscribe new transactions: %v", err)))
	}
	for {
		select {
		case err := <-sub.Err():
			log.Printf("subscribe new transactions error: %v", err)
			//subscribeFullPendingTx()
		case tx := <-pendingTx:
			//todo
			data, _ := tx.MarshalJSON()
			fmt.Println(string(data))
			/**
			{"type":"0x2","chainId":"0x1","nonce":"0x2","to":"0x417a5538c0af25ecea6a7eb87e66d553b34ad9ab","gas":"0x5208","gasPrice":null,"maxPriorityFeePerGas":"0x5f5e100","maxFeePerGas":"0x8a63c4190","value":"0xfd9caec58e1cce","input":"0x","accessList":[],"v":"0x0","r":"0xa878da21c2227d29bb4ae28d19238a80957880a2a04d04467f9aa3bde7dacc24","s":"0x3c267fb8d0348c2cec77d88179635db78055a16ee9da25a2c5d8beb51d8c2460","hash":"0xf3b2eb14180d1876f067c21397684874d22f1fc5b89219fb64868fad56712dec"}
			**/
		}
	}
}

func subscribePendingTxHash() {
	defer wg.Done()
	pendingTxHash := make(chan common.Hash)
	sub, err := subgclient.SubscribePendingTransactions(context.Background(), pendingTxHash)
	if err != nil {
		checkError(errors.New(fmt.Sprintf("subclient failed to subscribe new transactions hash: %v", err)))
	}
	for {
		select {
		case err := <-sub.Err():
			log.Printf("subscribe new transactions hash error: %v", err)
		case hash := <-pendingTxHash:
			log.Printf("Pending tx hash = %s", hash.Hex())
		}
	}
}

FilterLogs

过滤Logs

  • BlockHash + Addresses + Topics 用于过滤特定区块中的 logs
    • BlockHash 不为空的时候,FromBlock,ToBlock必须为空
  • FromBlock + ToBlock + Addresses + Topics 用于过滤区间 logs
    • FromBlock 为空时 arg["fromBlock"] = "0x0"
    • ToBlock 为空时 return "latest"
    • Topics,每笔交易中触发的事件是有顺序的,每个事件在当前区块中的顺序也是固定的
      • {} or nil 获取全部的 logs,推使用 switch xx case{} 解析不同的事件
      • {{A}} 当前交易触发事件中,只获取第一位是 A 的 log
      • {{},{B}} 当前交易触发事件中,只获取第二位是 B 的 log
      • {{A},{B}} 当前交易触发事件中,只获取第一位是 A 并且 第二位是 B 的 log
      • {{A,B},{C,D}} 当前交易触发事件中,只获取第一位是 A或者B 并且 第二位是 C或者D 的 log
// FilterQuery contains options for contract log filtering.
type FilterQuery struct {
	BlockHash *common.Hash     // used by eth_getLogs, return logs only from block with this hash
	FromBlock *big.Int         // beginning of the queried range, nil means genesis block
	ToBlock   *big.Int         // end of the range, nil means latest block
	Addresses []common.Address // restricts matches to events created by specific contracts

	// The Topic list restricts matches to particular event topics. Each event has a list
	// of topics. Topics matches a prefix of that list. An empty element slice matches any
	// topic. Non-empty elements represent an alternative that matches any of the
	// contained topics.
	//
	// Examples:
	// {} or nil          matches any topic list
	// {{A}}              matches topic A in first position
	// {{}, {B}}          matches any topic in first position AND B in second position
	// {{A}, {B}}         matches topic A in first position AND B in second position
	// {{A, B}, {C, D}}   matches topic (A OR B) in first position AND (C OR D) in second position
	Topics [][]common.Hash
}

无法获取监听合约的 abi

合约事件中,indexed 标记的字符存储在 Topic 数组,其余字段经过 abi.encode 编码后存储在 data 中

package main

import (
	"context"
	"errors"
	"fmt"
	"github.com/ethereum/go-ethereum"
	"github.com/ethereum/go-ethereum/common"
	"github.com/ethereum/go-ethereum/common/hexutil"
	"github.com/ethereum/go-ethereum/core/types"
	"github.com/ethereum/go-ethereum/ethclient"
	"log"
	"math/big"
	"strconv"
	"sync"
)

var (
	client *ethclient.Client
	err    error
)

// wss://ethereum.callstaticrpc.com wss://mainnet.gateway.tenderly.co wss://ws-rpc.graffiti.farm
func init() {
	client, err = ethclient.Dial("wss://ethereum-rpc.publicnode.com")
	if err != nil {
		checkError(errors.New(fmt.Sprintf("subclient failed to dial: %v", err)))
	}

}
func checkError(err error) {
	if err != nil {
		log.Fatalf("error = %v", err)
	}
}

var (
	wg       sync.WaitGroup
	logsChan = make(chan types.Log, 0)
)

func main() {
	contracts := []string{"0x8D22d933ad982Fa7EbCc589A7cA69438E9320e55"}
	topics := []string{"0xc3d58168c5ae7397731d063d5bbf3d657854427343f4c083240f7aacaa2d0f62", "0xb48a3f4dab3b7d9c6a59f8fd2b97e1336ce85b34bc2c9bc96b59f964cfa7b0c3"}
	wg.Add(2)
	go FilterLogs(21270408, 21270409, contracts, topics)
	go parseLogs()
	wg.Wait()
}
func FilterLogs(startBlockHeight, latestBlockNum int64, addresses []string, topics []string) {
	defer wg.Done()
	i := startBlockHeight
	for i <= latestBlockNum {
		from := &big.Int{}
		from = from.SetInt64(startBlockHeight)
		i += 5000
		to := &big.Int{}
		if i > latestBlockNum {
			to = to.SetInt64(latestBlockNum)
		} else {
			to = to.SetInt64(i)
		}
		query := ethereum.FilterQuery{
			FromBlock: from,
			ToBlock:   to,
		}
		for _, address := range addresses {
			query.Addresses = append(query.Addresses, common.HexToAddress(address))
		}
		top := make([]common.Hash, 0)
		for _, topic := range topics {
			top = append(top, common.HexToHash(topic))
		}
		query.Topics = append(query.Topics, top)

		fmt.Println(query)
		logs, err := client.FilterLogs(context.Background(), query)
		if err != nil {
			checkError(errors.New(fmt.Sprintf("Error in filter logs :%v", err)))
		}
		for _, logData := range logs {
			logsChan <- logData
		}
	}
}

func parseLogs() {
	defer wg.Done()
	for {
		select {
		case logData := <-logsChan:
			switch logData.Topics[0] {
			case common.HexToHash("0xc3d58168c5ae7397731d063d5bbf3d657854427343f4c083240f7aacaa2d0f62"):
				//time.Sleep(500 * time.Millisecond)
				Operator := common.HexToAddress(logData.Topics[1].Hex())
				From := common.HexToAddress(logData.Topics[2].Hex())
				To := common.HexToAddress(logData.Topics[3].Hex())
				Id, _ := strconv.ParseInt(hexutil.Encode(logData.Data)[2:66], 16, 64)
				Value, _ := strconv.ParseInt(hexutil.Encode(logData.Data)[66:], 16, 64)
				fmt.Printf("NFT: %s, Operator: %s, From: %s, To: %s, NFTId: %d, Value: %d\n", logData.Address, Operator, From, To, Id, Value)
			case common.HexToHash("0xb48a3f4dab3b7d9c6a59f8fd2b97e1336ce85b34bc2c9bc96b59f964cfa7b0c3"):
				start := 2
				end := start + 64
				Tier, _ := strconv.ParseInt(hexutil.Encode(logData.Data)[start:end], 16, 64)
				start = end
				end = start + 64
				Collection := common.HexToAddress(hexutil.Encode(logData.Data)[start:end])
				start = end
				end = start + 64
				Id, _ := strconv.ParseInt(hexutil.Encode(logData.Data)[start:end], 16, 64)
				fmt.Printf("Collection: %s, Tier: %d, NFTId: %d\n", Collection, Tier, Id)
			}

		}
	}
}

能够获取监听合约的 abi

通过 abi 文件直接按照 abi.decode 的规则直接解析 logs 数据

abigen --abi=xxx.abi --bin=xxx.bin --pkg=xxx --out=xxx.go

package main

import (
	"context"
	"errors"
	"fmt"
	"github.com/ethereum/go-ethereum"
	"github.com/ethereum/go-ethereum/common"
	"github.com/ethereum/go-ethereum/core/types"
	"github.com/ethereum/go-ethereum/ethclient"
	"log"
	"math/big"
	"sync"
)

var (
	client *ethclient.Client
	err    error
)

// wss://ethereum.callstaticrpc.com wss://mainnet.gateway.tenderly.co wss://ws-rpc.graffiti.farm
func init() {
	client, err = ethclient.Dial("wss://ethereum-rpc.publicnode.com")
	if err != nil {
		checkError(errors.New(fmt.Sprintf("subclient failed to dial: %v", err)))
	}

}
func checkError(err error) {
	if err != nil {
		log.Fatalf("error = %v", err)
	}
}

var (
	wg       sync.WaitGroup
	logsChan = make(chan types.Log, 0)
)

func main() {
	contracts := []string{"0x8D22d933ad982Fa7EbCc589A7cA69438E9320e55"}
	topics := []string{"0xc3d58168c5ae7397731d063d5bbf3d657854427343f4c083240f7aacaa2d0f62", "0xb48a3f4dab3b7d9c6a59f8fd2b97e1336ce85b34bc2c9bc96b59f964cfa7b0c3"}
	wg.Add(2)
	go FilterLogs(21270480, 21270481, contracts, topics)
	go parseLogs()
	wg.Wait()
}
func FilterLogs(startBlockHeight, latestBlockNum int64, addresses []string, topics []string) {
	defer wg.Done()
	i := startBlockHeight
	for i <= latestBlockNum {
		from := &big.Int{}
		from = from.SetInt64(startBlockHeight)
		i += 5000
		to := &big.Int{}
		if i > latestBlockNum {
			to = to.SetInt64(latestBlockNum)
		} else {
			to = to.SetInt64(i)
		}
		query := ethereum.FilterQuery{
			FromBlock: from,
			ToBlock:   to,
		}
		for _, address := range addresses {
			query.Addresses = append(query.Addresses, common.HexToAddress(address))
		}
		top := make([]common.Hash, 0)
		for _, topic := range topics {
			top = append(top, common.HexToHash(topic))
		}
		query.Topics = append(query.Topics, top)

		logs, err := client.FilterLogs(context.Background(), query)
		if err != nil {
			checkError(errors.New(fmt.Sprintf("Error in filter logs :%v", err)))
		}
		for _, logData := range logs {
			logsChan <- logData
		}
	}
}

func parseLogs() {
	defer wg.Done()
	for {
		select {
		case logData := <-logsChan:
			switch logData.Topics[0] {
			case common.HexToHash("0xb48a3f4dab3b7d9c6a59f8fd2b97e1336ce85b34bc2c9bc96b59f964cfa7b0c3"):
				stoneFilterer := stoneDropFilterer(logData.Address)
				stoneDropped, err := stoneFilterer.ParseMysteryBoxDropped(logData)
				if err != nil {
					checkError(errors.New(fmt.Sprintf("Error in parse logs = %v", err)))

				}
				Tier := stoneDropped.Tier
				Collection := stoneDropped.Collection
				Id := stoneDropped.Id
				fmt.Printf("Collection: %s, Tier: %d, NFTId: %d\n", Collection, Tier, Id)
			}

		}
	}
}
func stoneDropFilterer(address common.Address) *StonedropFilterer {
	StoneFilterer, err := NewStonedropFilterer(address, client)
	if err != nil {
		checkError(errors.New(fmt.Sprintf("Error in bind contract filter")))
	}
	return StoneFilterer
}

订阅Logs

func SubStakingEvent() {
	defer wg.Done()
	query := ethereum.FilterQuery{
		Addresses: []common.Address{common.HexToAddress(contract)},
		Topics:    [][]common.Hash{{common.HexToHash("0x5ad8141c164356bdef9e16f08312a7034ac6682a7413ce4fecfc44da5e18fec7")}, {common.HexToHash("0xeb879c9d6d39266b9caad39ced3788f8b8f47bb316e3fb55f3f44cb0f638cbc6")}},
	}
	subevents, err := mainnetLPClient.SubscribeFilterLogs(context.Background(), query, logsChan)
	if err != nil {
		fmt.Println(fmt.Errorf("Subscribe Event error: %v", err))
		log.Fatal(err)
	}
	for {
		select {
		case err := <-subevents.Err():
			fmt.Println(fmt.Errorf("Parse Event error: %v", err))
			SubStakingEvent()
		case lplog := <-logs:
			time.Sleep(500 * time.Millisecond)
			parseStakingEventLogs(StakingFilterer, lplog, 1)
	}
}

多协程处理Logs

package main

import (
	"context"
	"github.com/ethereum/go-ethereum"
	"github.com/ethereum/go-ethereum/common"
	"github.com/ethereum/go-ethereum/core/types"
	"log"
	"main/config"
	"main/dbop"
	"main/eth"
	"math/big"
	"sync"
)

var (
	headers map[string]chan *types.Header
	logs    map[string]chan types.Log
)

func init() {
	headers = make(map[string]chan *types.Header)
	logs = make(map[string]chan types.Log)

	for _, chain := range config.ChainName {
		eth.BuildFilterClient(chain)
		headers[chain] = make(chan *types.Header)
		logs[chain] = make(chan types.Log) // 设置缓冲区
	}
}

func main() {
	var wg sync.WaitGroup
	start, end := 54099128, 61343736
	interval := (end - start) / 5 // 根据需要划分区间

	for _, chain := range config.ChainName {
		chainCopy := chain

		// 启动多个协程进行 FilterLogs
		for i := 0; i < 10; i++ { // 假设我们要启动 10 个协程
			s1 := start + interval*i
			e1 := s1 + interval

			wg.Add(1)
			go func(s, e int, c string) {
				defer wg.Done()
				DealHistoryRangeTx(s, e, c) // 假设 FilterLogs 函数接收 start 和 end
			}(s1, e1, chainCopy)
		}

		//wg.Add(1)
		//go func(c string) {
		//	defer wg.Done()
		//	DealHistoryTx(c)
		//}(chainCopy)

		wg.Add(1)
		go func(c string) {
			defer wg.Done()
			ParseEventLog(c)
		}(chainCopy)

		// 如果需要监听区块,可以取消注释以下代码
		/*
			wg.Add(1)
			go func(c string) {
				defer wg.Done()
				listenBlocks(c)
			}(chainCopy)
		*/
	}

	wg.Wait() // 等待所有协程完成
}


func listenBlocks(chain string) {
	client := eth.ClientFromChain[chain]
	subheaders, err := client.SubscribeNewHead(context.Background(), headers[chain])
	if err != nil {
		log.Fatalf("Subscribe Block error for chain %s: %v", chain, err)
	}
	defer subheaders.Unsubscribe() // 确保在结束时取消订阅

	for {
		select {
		case err := <-subheaders.Err():
			log.Fatalf("Subscribe Block error for chain %s: %v", chain, err)
		case header := <-headers[chain]:
			expectBlockNum := dbop.GetStartBlockNumber(chain, config.OIKContractFromChain[chain])
			if header.Number.Uint64() > expectBlockNum {
				query := ethereum.FilterQuery{
					FromBlock: big.NewInt(int64(expectBlockNum)),
					ToBlock:   header.Number,
				}
				query.Addresses = append(query.Addresses, common.HexToAddress(config.OIKContractFromChain[chain]))

				top := make([]common.Hash, 0)
				for _, topic := range config.TopicFromChain[chain] {
					top = append(top, common.HexToHash(topic))
				}
				query.Topics = append(query.Topics, top)

				// 使用 goroutine 获取日志
				elog, err := client.FilterLogs(context.Background(), query)
				if err != nil {
					log.Fatalf("FilterLogs error for chain %s: %v", chain, err)
				}
				for _, logData := range elog {
					logs[chain] <- logData
				}
			}
			//case elog := <-logs[chain]:
			//log.Printf("Received logs for chain %s: %d logs", chain, len(elog))
			//ParseEventLog(chain, elog)
		}
	}
}

func DealHistoryRangeTx(start, end int, chain string) {
	//start := dbop.GetStartBlockNumber(chain, config.OIKContractFromChain[chain])
	//end := eth.ChainBlockNumber(chain)
	contract := config.OIKContractFromChain[chain]
	topics := config.TopicFromChain[chain]
	client := eth.ClientFromChain[chain]
	for start <= end {
		from := &big.Int{}
		from = from.SetInt64(int64(start))
		start += 5000
		to := &big.Int{}
		if start > end {
			to = to.SetInt64(int64(end))
		} else {
			to = to.SetInt64(int64(start))
		}
		query := ethereum.FilterQuery{
			FromBlock: from,
			ToBlock:   to,
		}
		query.Addresses = append(query.Addresses, common.HexToAddress(contract))

		top := make([]common.Hash, 0)
		for _, topic := range topics {
			top = append(top, common.HexToHash(topic))
		}
		query.Topics = append(query.Topics, top)
		elog, err := client.FilterLogs(context.Background(), query)
		if err != nil {
			log.Fatalf("FilterLogs error for chain %s: %v", chain, err)
		}
		for _, logData := range elog {
			logs[chain] <- logData
		}
	}
}

func ParseEventLog(chain string) {
	for {
		select {
		case logdata := <-logs[chain]:
			dbop.InsertTxLog(chain, logdata)
		}
	}
}

SendValue

以太坊账户分为: EOA 和 合约账户

  • EOA, 通过私钥直接签名/发送交易
  • 合约账户由 EOA 交易触发,交易中的 data 发送到合约执行逻辑

LegacyTransactions

1559 升级前的交易类型,不需要考虑当前手续费类型,从直接给定的手续费中扣除 baseFee+ExecuteFee 后的 Fee * GasUsed 就是矿工小费

  • Nonce: 链上递增值,防止重放交易
    • 构建交易需要先获取当前交易的最新 nonce 值,推荐使用 pendingNonce
  • GasPrice: 发送地址附加的 gasFee
  • Gas:发送方附加的 gas 数量
    • 当前账户的余额必须大于 gas * gasPrice
  • To: 交易的接收方
    • to == nil, 该交易是合约创建交易,交易的 data 就是合约创建的代码
    • to = EOA,交易仅仅能转账,data 作为附加值放在转账 data 中
    • to = SmartContract, 合约的调用交易,data 整体发送到合约地址按照合约逻辑执行
  • Value: 发送方转账到 to 地址的余额
  • Data: 发送交易附加的 data
    • 在 to == nil 的时候,表示合约的创建代码
    • 在 to = EOA 的时候,表示一个备注,没有实际意义
    • 在 to = SmartContract 的时候,表示需要在合约代码中执行的函数以及传参数据
// LegacyTx is the transaction data of the original Ethereum transactions.
type LegacyTx struct {
	Nonce    uint64          // nonce of sender account
	GasPrice *big.Int        // wei per gas
	Gas      uint64          // gas limit
	To       *common.Address `rlp:"nil"` // nil means contract creation
	Value    *big.Int        // wei amount
	Data     []byte          // contract invocation input data
	V, R, S  *big.Int        // signature values
}

DynamicTransactions

1559 升级后将手续费分成三部分

  • baseFee: 链网络全局数据,和当前网络拥堵情况相关
  • maxPriorityFeePerGas: 发送方愿意发送到矿工的最大小费单位
  • maxFeePerGas: 发送方愿意为当前交易付出的最大 gas 价格
  • 实际运行过程中的 gasPricePerGas = min(maxPriorityFeePerGas + baseFee, maxFeePerGas)
  • AccessList 用于在构建交易提前指定当前交易会 sload/sstore 的 slot 键值
// DynamicFeeTx represents an EIP-1559 transaction.
type DynamicFeeTx struct {
	ChainID    *big.Int
	Nonce      uint64
	GasTipCap  *big.Int // a.k.a. maxPriorityFeePerGas
	GasFeeCap  *big.Int // a.k.a. maxFeePerGas
	Gas        uint64
	To         *common.Address `rlp:"nil"` // nil means contract creation
	Value      *big.Int
	Data       []byte
	AccessList AccessList

	// Signature values
	V *big.Int `json:"v" gencodec:"required"`
	R *big.Int `json:"r" gencodec:"required"`
	S *big.Int `json:"s" gencodec:"required"`
}

构建转账交易

  1. 从私钥导出交易发送方
  2. 获取发送方的 Nonce 值
  3. 获取 gasPrice (两种转账类型获取不同的 gasPrice )
  4. 基于 Nonce,To,Value,gasPrice,data 构建交易
  5. 私钥签名数据
  6. 通过 RPC 节点发送交易
package main

import (
	"bytes"
	"context"
	"crypto/ecdsa"
	"encoding/hex"
	"errors"
	"fmt"
	"github.com/ethereum/go-ethereum/common"
	"github.com/ethereum/go-ethereum/common/hexutil"
	"github.com/ethereum/go-ethereum/core/types"
	"github.com/ethereum/go-ethereum/crypto"
	"github.com/ethereum/go-ethereum/ethclient"
	"github.com/ethereum/go-ethereum/params"
	"log"
	"math/big"
	"strings"
)

var (
	client *ethclient.Client
	err    error
)

// wss://ethereum.callstaticrpc.com wss://mainnet.gateway.tenderly.co wss://ws-rpc.graffiti.farm wss://ethereum-rpc.publicnode.com
func init() {
	client, err = ethclient.Dial("wss://sepolia.drpc.org")
	if err != nil {
		checkError(errors.New(fmt.Sprintf("subclient failed to dial: %v", err)))
	}

}
func checkError(err error) {
	if err != nil {
		log.Fatalf("error = %v", err)
	}
}

func main() {
	DynamicTransferEth("0x604427A2d0805F7037d2747c2B4D882116616cb9", "", 10, 21000)
}

func DynamicTransferEth(to, data string, value, gasLimit uint64) {
	var ctx = context.Background()
	// Import the from address
	//0x96216849c49358B10257cb55b28eA603c874b05E
	key := "fad9c8855b740a0b7ed4c221dbad0f33a83a49cad6b3fe8d5817ac83d38b6a19"
	// Decode the provided private key.
	if ok := strings.HasPrefix(key, "0x"); ok {
		key, _ = strings.CutPrefix(key, "0x")
	}
	ecdsaPrivateKey, err := crypto.HexToECDSA(key)
	if err != nil {
		log.Fatalf("Errors in parsing key %v", err)
	}
	publicKey := ecdsaPrivateKey.Public()
	publicKeyECDSA, ok := publicKey.(*ecdsa.PublicKey)
	if !ok {
		log.Fatal("Error casting public key to ECDSA")
	}

	// Compute the Ethereum address of the signer from the public key.
	fromAddress := crypto.PubkeyToAddress(*publicKeyECDSA)
	// Retrieve the nonce for the signer's account, representing the transaction count.

	nonce, err := client.PendingNonceAt(context.Background(), fromAddress)
	if err != nil {
		log.Fatal(err)
	}

	// Prepare data payload.
	if ok := strings.HasPrefix(data, "0x"); !ok {
		data = hexutil.Encode([]byte(data))
	}

	bytesData, err := hexutil.Decode(data)
	//bytesData, err := hexutil.Decode(hexData)
	if err != nil {
		log.Fatalln(err)
	}

	// Set up the transaction fields, including the recipient address, value, and gas parameters.
	toAddr := common.HexToAddress(to)
	amount := new(big.Int).SetUint64(value)
	_, priorityFee, gasFeeCap := DynamicGasPrice(ctx)

	chainID, err := client.ChainID(ctx)
	if err != nil {
		log.Fatalln(err)
	}

	txData := types.DynamicFeeTx{
		ChainID:   chainID,
		Nonce:     nonce,
		GasTipCap: priorityFee,
		GasFeeCap: gasFeeCap,
		Gas:       gasLimit,
		To:        &toAddr,
		Value:     amount,
		Data:      bytesData,
	}

	tx := types.NewTx(&txData)

	// Sign the transaction with the private key of the sender.
	signedTx, err := types.SignTx(tx, types.LatestSignerForChainID(chainID), ecdsaPrivateKey)
	if err != nil {
		log.Fatalln(err)
	}

	// Encode the signed transaction into RLP (Recursive Length Prefix) format for transmission.
	var buf bytes.Buffer
	err = signedTx.EncodeRLP(&buf)

	if err != nil {
		log.Fatalln(err)
	}

	// Return the RLP-encoded transaction as a hexadecimal string.
	rawTxRLPHex := hex.EncodeToString(buf.Bytes())

	//fmt.Printf("tx details: %s,  build hash: %s", rawTxRLPHex, signedTx.Hash().Hex())

	err = client.SendTransaction(ctx, signedTx)
	if err != nil {
		log.Fatal(err)
	} else {
		fmt.Printf("tx details: %s,  build hash: %s", rawTxRLPHex, signedTx.Hash().Hex())
      //https://sepolia.etherscan.io/tx/0x536b59ae57a4af9593a964c274a27fc35e31f002bab099b8ab1f3c14dc2f1827
	}
}

func DynamicGasPrice(ctx context.Context) (*big.Int, *big.Int, *big.Int) {
	// Suggest the base fee for inclusion in a block.
	baseFee, err := client.SuggestGasPrice(ctx)
	if err != nil {
		log.Fatalln(err)
	}

	// Suggest a gas tip cap (priority fee) for miner incentive.
	priorityFee, err := client.SuggestGasTipCap(ctx)
	if err != nil {
		log.Fatalln(err)
	}

	// Calculate the maximum gas fee cap, adding a 2 GWei margin to the base fee plus priority fee.
	increment := new(big.Int).Mul(big.NewInt(2), big.NewInt(params.GWei))
	gasFeeCap := new(big.Int).Add(baseFee, increment)
	gasFeeCap.Add(gasFeeCap, priorityFee)

	return baseFee, priorityFee, gasFeeCap
}

LegacyTransactions 获取 gasPrice

func main() {
	LegacyTransferEth("0x604427A2d0805F7037d2747c2B4D882116616cb9", "", 10, 21000)
}

func LegacyTransferEth(to, data string, value, gasLimit uint64) {
	gasPrice := SuggestedGasPrice(ctx)
	txData := types.LegacyTx{
		Nonce:    nonce,
		GasPrice: gasPrice,
		Gas:      gasLimit,
		To:       &toAddr,
		Value:    amount,
		Data:     bytesData,
	}
}

func SuggestedGasPrice(ctx context.Context) *big.Int {
	// Retrieve the currently suggested gas price for a new transaction.
	gasPrice, err := client.SuggestGasPrice(ctx)
	if err != nil {
		log.Fatalf("Failed to suggest gas price: %v", err)
	}
	return gasPrice
}
//tx details: f8684585044dc8d30782520894604427a2d0805f7037d2747c2b4d882116616cb90a808401546d72a0b3f69a384be92af611e0c9a48f9bfb7e5e878d13b672f7bb750f3e80255a951ca0110af4963209ab2636adbc7b16d8500b53beb79694db044c534a21d0ef0c7d05,  build hash: 0x3735c2ef92c4631fef1d7b927d085e3b5a942cb9115e117c8566fd1262cdb390

预估交易 Gas

模拟当前交易执行需要的 gas 花销,该交易不会发送到链上执行,只会在 rpc 节点本地模拟执行

  • From: 交易的构造地址
  • To: 交易的接收方。EOA 或者合约地址
  • Gas:0 表示在模拟执行过程中,gas 不限量,用来模拟出最终的花销
  • Value:交易附加的 Value,转账需要花费额外的 gas,所以需要表明
  • Data:交易附加的 data,data 数据上链或者发送到合约处理,都需要额外的 gas
// CallMsg contains parameters for contract calls.
type CallMsg struct {
	From      common.Address  // the sender of the 'transaction'
	To        *common.Address // the destination contract (nil for contract creation)
	Gas       uint64          // if 0, the call executes with near-infinite gas
	GasPrice  *big.Int        // wei <-> gas exchange ratio
	GasFeeCap *big.Int        // EIP-1559 fee cap per gas.
	GasTipCap *big.Int        // EIP-1559 tip per gas.
	Value     *big.Int        // amount of wei sent along with the call
	Data      []byte          // input data, usually an ABI-encoded contract method invocation

	AccessList types.AccessList // EIP-2930 access list.

	// For BlobTxType
	BlobGasFeeCap *big.Int
	BlobHashes    []common.Hash
}

RPC 节点在本地基于当前 Pending 的链状态模拟执行该交易,但是不能保证和真实链上 gas 消耗完全一致,仅能作为参考

因为,在模拟执行和真实上链的空隙,有可能存在其他交易更新了和当前交易相关地址的数据,造成 gas 消耗的增加或减少

代码


func EstimateGas(from, to string, data []interface{}, value uint64) uint64 {
	var ctx = context.Background()
	var err error
	var (
		fromAddr  = common.HexToAddress(from)     // Convert the from address from hex to an Ethereum address.
		toAddr    = common.HexToAddress(to)       // Convert the to address from hex to an Ethereum address.
		amount    = new(big.Int).SetUint64(value) // Convert the value from uint64 to *big.Int.
		bytesData []byte
	)

	// Encode the data if it's not already hex-encoded.
	bytesData = encodeData(data)

	// Create a message which contains information about the transaction.
	msg := ethereum.CallMsg{
		From:  fromAddr,
		To:    &toAddr,
		Gas:   0x00,
		Value: amount,
		Data:  bytesData,
	}

	// Estimate the gas required for the transaction.
	gas, err := client.EstimateGas(ctx, msg)
	if err != nil {
		log.Fatalln(err)
	}

	return gas
}

WriteTokenTransferRawTx

to = SmartContract, 表示合约的调用交易,data 整体发送到合约地址按照合约逻辑执行

合约函数的调用由两部分组成:selector + 传参编码

  • 代币转账函数为transfer(address,uint256)
    • 首先计算 selector = keccak256("transfer(address,uint256)")[:4]
  • 函数需要的两个传参按照 abi.encode 编码向左补全 256bit
    • data = append(data, common.LeftPadBytes(receiver, 32)...)

完整代码

package main

import (
	"bytes"
	"context"
	"crypto/ecdsa"
	"encoding/hex"
	"errors"
	"fmt"
	"github.com/ethereum/go-ethereum/common"
	"github.com/ethereum/go-ethereum/common/hexutil"
	"github.com/ethereum/go-ethereum/core/types"
	"github.com/ethereum/go-ethereum/crypto"
	"github.com/ethereum/go-ethereum/ethclient"
	"golang.org/x/crypto/sha3"
	"log"
	"math/big"
	"strconv"
	"strings"
)

var (
	client *ethclient.Client
	err    error
)

// wss://ethereum.callstaticrpc.com wss://mainnet.gateway.tenderly.co wss://ws-rpc.graffiti.farm wss://ethereum-rpc.publicnode.com
func init() {
	client, err = ethclient.Dial("wss://sepolia.drpc.org")
	if err != nil {
		checkError(errors.New(fmt.Sprintf("subclient failed to dial: %v", err)))
	}

}
func checkError(err error) {
	if err != nil {
		log.Fatalf("error = %v", err)
	}
}

func main() {
	data := []interface{}{"transfer(address,uint256)", "0x604427A2d0805F7037d2747c2B4D882116616cb9", "200"}
	writeTokenTransferRawTx("0x779877A7B0D9E8603169DdbD7836e478b4624789", data, 0, 60000)
}

func writeTokenTransferRawTx(to string, data []interface{}, value, gasLimit uint64) {
	var ctx = context.Background()
	// Import the from address
	//0x96216849c49358B10257cb55b28eA603c874b05E
	key := "fad9c8855b740a0b7ed4c221dbad0f33a83a49cad6b3fe8d5817ac83d38b6a19"
	// Decode the provided private key.
	if ok := strings.HasPrefix(key, "0x"); ok {
		key, _ = strings.CutPrefix(key, "0x")
	}
	ecdsaPrivateKey, err := crypto.HexToECDSA(key)
	if err != nil {
		log.Fatalf("Errors in parsing key %v", err)
	}
	publicKey := ecdsaPrivateKey.Public()
	publicKeyECDSA, ok := publicKey.(*ecdsa.PublicKey)
	if !ok {
		log.Fatal("Error casting public key to ECDSA")
	}

	// Compute the Ethereum address of the signer from the public key.
	fromAddress := crypto.PubkeyToAddress(*publicKeyECDSA)
	// Retrieve the nonce for the signer's account, representing the transaction count.

	nonce, err := client.PendingNonceAt(context.Background(), fromAddress)
	if err != nil {
		log.Fatal(err)
	}

	bytesData := encodeData(data)

	// Set up the transaction fields, including the recipient address, value, and gas parameters.
	toAddr := common.HexToAddress(to)
	amount := new(big.Int).SetUint64(value)

	chainID, err := client.ChainID(ctx)
	if err != nil {
		log.Fatalln(err)
	}
	gasPrice := SuggestedGasPrice(ctx)
	txData := types.LegacyTx{
		Nonce:    nonce,
		GasPrice: gasPrice,
		Gas:      gasLimit,
		To:       &toAddr,
		Value:    amount,
		Data:     bytesData,
	}

	tx := types.NewTx(&txData)

	// Sign the transaction with the private key of the sender.
	signedTx, err := types.SignTx(tx, types.LatestSignerForChainID(chainID), ecdsaPrivateKey)
	if err != nil {
		log.Fatalln(err)
	}

	// Encode the signed transaction into RLP (Recursive Length Prefix) format for transmission.
	var buf bytes.Buffer
	err = signedTx.EncodeRLP(&buf)

	if err != nil {
		log.Fatalln(err)
	}

	// Return the RLP-encoded transaction as a hexadecimal string.
	rawTxRLPHex := hex.EncodeToString(buf.Bytes())

	//fmt.Printf("tx details: %s,  build hash: %s", rawTxRLPHex, signedTx.Hash().Hex())

	err = client.SendTransaction(ctx, signedTx)
	if err != nil {
		log.Fatal(err)
	} else {
		fmt.Printf("tx details: %s,  build hash: %s", rawTxRLPHex, signedTx.Hash().Hex())

	}
}

func SuggestedGasPrice(ctx context.Context) *big.Int {
	// Retrieve the currently suggested gas price for a new transaction.
	gasPrice, err := client.SuggestGasPrice(ctx)
	if err != nil {
		log.Fatalf("Failed to suggest gas price: %v", err)
	}
	return gasPrice
}

func encodeData(datas []interface{}) []byte {
	// Prepare data payload.
	funcs := datas[0].(string)
	receipt := datas[1].(string)
	tokenValue := datas[2].(string)

	hash := sha3.NewLegacyKeccak256()
	hash.Write([]byte(funcs))
	methodID := hash.Sum(nil)[:4]
	var data []byte
	data = append(data, methodID...)
	rece, _ := common.ParseHexOrString(strings.ToLower(receipt))
	data = append(data, common.LeftPadBytes(rece, 32)...)

	number, _ := strconv.ParseInt(tokenValue, 10, 64)
	s := hexutil.EncodeBig(big.NewInt(number))

	byteD, err := hexutil.Decode(s)
	if err != nil {
		checkError(err)
	}
	data = append(data, common.LeftPadBytes(byteD, 32)...)

	//res := hexutil.Encode(data)
	//if ok := strings.HasPrefix(res, "0x"); !ok {
	//	res = hexutil.Encode([]byte(res))
	//}
	return data
}

ReadContract

solc

合约编译时会编译成两部分: 字节码(bin) + 合约函数的二进制接口(abi)

  • bin 跟随交易部署到合约地址
  • abi 文件用来调用合约函数

Solc工具用于编译合约

  • solc --abi xx.sol, 生成合约 abi 文件
  • solc --bin xx.sol,生成合约的 bin 文件

abigen工具可以基于 abi 和 bin 文件创建合约部署和调用的 Golang 文件

  • abigen --abi=xxx.abi --bin=xxx.bin --pkg=xxx --out=xxx.go
    • 仅仅提供 abi 文件的话,只能生成合约的调用文件,不能部署合约

代码

package milestone2

import (
	"github.com/ethereum/go-ethereum/common"
	"math/big"
	"strconv"
)

func StakingInfo(contract, nft, nftId string) (common.Address, int64, error) {
	var staker common.Address
	stakingEndTime := int64(0)
	address := common.HexToAddress(contract)
	instance, err := NewStakeCaller(address, client)
	if err != nil {
		return staker, stakingEndTime, err
	}
	nftid, err := strconv.ParseInt(nftId, 10, 64)
	if err != nil {
		return staker, stakingEndTime, err
	}
	staker, endts, err := instance.RegisterData(nil, common.HexToAddress(nft), big.NewInt(nftid))
	if err != nil {
		return staker, stakingEndTime, err
	}
	return staker, endts.Int64(), nil
}

WriteContract

链上写入交易会更新链上数据状态,因此需要发送方签名和付费,保证交易的完整性

  1. 将合约 abi 调用程序和 RPC 节点绑定
  2. 构建交易
  • 从私钥导出账户
  • 获取账户 nonce
  • 配置交易 gasLimit、gasPrice
  1. 私钥签名并发送交易
package milestone2

import (
	"context"
	"crypto/ecdsa"
	"fmt"
	"github.com/ethereum/go-ethereum/accounts/abi/bind"
	"github.com/ethereum/go-ethereum/common"
	"github.com/ethereum/go-ethereum/crypto"
	"math/big"
)

func WriteExtend(registerContract, nftContract common.Address, NFTIds []*big.Int, time uint64, pk *ecdsa.PrivateKey) (error, string) {
	instance, err := NewStake(registerContract, client)
	if err != nil {
		return fmt.Errorf("WriteExtend构建延期交易Instance失败"), ""
	}
	publicKey := pk.Public()
	publicKeyECDSA, ok := publicKey.(*ecdsa.PublicKey)
	if !ok {
		return fmt.Errorf("WriteExtend导出公钥地址失败"), ""
	}

	fromAddress := crypto.PubkeyToAddress(*publicKeyECDSA)
	chainId, err := client.ChainID(context.Background())
	opts, err := bind.NewKeyedTransactorWithChainID(pk, chainId)
	if err != nil {
		return fmt.Errorf("WriteExtend绑定链数据失败"), ""
	}

	nonce, err := client.PendingNonceAt(context.Background(), fromAddress)
	if err != nil {
		return fmt.Errorf("WriteExtend获取地址nonce失败"), ""
	}

	opts.Nonce = big.NewInt(int64(nonce))
	limit := len(NFTIds) * 50000
	opts.GasLimit = uint64(limit)
	//opts.GasPrice, err = config.Client.SuggestGasPrice(context.Background())
	suggestPrice, err := client.SuggestGasPrice(context.Background())
	if err != nil {
		return fmt.Errorf("WriteExtend获取GasPrice失败"), ""
	}
	opts.GasPrice = new(big.Int).Mul(new(big.Int).Div(suggestPrice, big.NewInt(10)), big.NewInt(13))
	//opts.GasPrice = suggestPrice

	trans, err := instance.RegistrationRenewal(opts, nftContract, NFTIds, time)

	if err != nil {
		return fmt.Errorf("WriteExtend发送交易失败"), ""
	}
	//fmt.Printf("---No.%d 交易成功 %s Hash: %s \n", index, fromAddress, trans.Hash().Hex())
	return nil, trans.Hash().Hex()
}

GetSlotData

合约数据全部按照Solidity Slot 存储规则存储在区块链上,因此只要上链的数据就能通过 slot 键获取值

GetSlotByKey

合约数据按照声明顺序和编码规则存储在链上空间

读取区块内slot值

func GetStorageAtBlock(ctx context.Context, address common.Address, slot common.Hash, blockNum *big.Int) (*big.Int, error) {
	//t := common.BigToHash(big.NewInt(int64(slot)))
	int256 := new(big.Int)
	res, err := client.StorageAt(ctx, address, slot, blockNum) // nil is the latest blockNum
	if err != nil {
		return int256, err
	}
	int256.SetBytes(res)
	return int256, nil
}

读取区块slot值

基于区块 hash 锁定区块,读取截止区块高度的合约数据的 slot 数值

  • hash 不存在:报错 error = header for hash not found
func GetStorageAtHash(ctx context.Context, address common.Address, slot common.Hash, hash common.Hash) (*big.Int, error) {
	int256 := new(big.Int)
	res, err := client.StorageAtHash(ctx, address, slot, hash)
	if err != nil {
		return int256, err
	}
	int256.SetBytes(res)

	return int256, nil
}

PendingStorage


func GetPendingStorage(ctx context.Context, address common.Address, slot common.Hash) (*big.Int, error) {
	int256 := new(big.Int)
	res, err := client.PendingStorageAt(ctx, address, slot)
	if err != nil {
		return int256, err
	}
	int256.SetBytes(res)

	return int256, nil
}

ContractSlotParser

合约内部的存储结构通过标准的 json 请求可以获取

solc --storage-layout --pretty-json -o $PWD/tempDirForSolc --overwrite ./xxx.sol

Json 对象包含两个键值: storage 和 types

Storage

{
    "astId": 2,
    "contract": "fileA:A",
    "label": "x",
    "offset": 0,
    "slot": "0",
    "type": "t_uint256"
}
  • astId:状态变量声明的 AST 节点的 ID
  • contract: 当前合约名称
  • label:状态变量的名称
  • offset:字节偏移量,表示在当前 slot 中的偏移量
  • slot:存储的插槽位置
  • type:标识符,表示具体的数据存储,在 types 中存在对应的结构体数据

Type

{
    "base": "t_bool",
    "encoding": "inplace",
    "label": "uint256",
    "numberOfBytes": "32",
    "key": "t_string_memory_ptr",
    "value": "t_uint256",
    "members": `Type` 数组
}

基础数据结构:

  • encoding:数据编码方式
    • inplace:数据能够在插槽中连续存储的数据
    • mapping:基于 Keccak-256 寻址
    • dynamic_array:基于 Keccak-256 寻址
    • bytes:单槽或基于 Keccak-256 哈希值,取决于数据大小
  • label:类型名称
  • numberOfBytes: 数据存储占据的字节数,如果 大于 32,表示使用一个以上的插槽存储数据

其中, mapping 类型额外包含

  • key: 键值类型
  • value:值类型
    "t_mapping(t_uint256,t_mapping(t_address,t_uint256))": {
      "encoding": "mapping",
      "key": "t_uint256",
      "label": "mapping(uint256 => mapping(address => uint256))",
      "numberOfBytes": "32",
      "value": "t_mapping(t_address,t_uint256)"
    },

数组包含:

  • base: 数组成员的数据类型
    "t_array(t_bool)5_storage": {
      "base": "t_bool",
      "encoding": "inplace",
      "label": "bool[5]",
      "numberOfBytes": "32"
    },

结构体包含:

  • members: 数组类型,表示结构体内部每个值的类型
    "t_struct(Entity)62_storage": {
      "encoding": "inplace",
      "label": "struct StorageScan.Entity",
      "members": [
        {
          "astId": 57,
          "contract": "StorageScan.sol:StorageScan",
          "label": "age",
          "offset": 0,
          "slot": "0",
          "type": "t_uint64"
        },
        {
          "astId": 59,
          "contract": "StorageScan.sol:StorageScan",
          "label": "id",
          "offset": 8,
          "slot": "0",
          "type": "t_uint128"
        },
        {
          "astId": 61,
          "contract": "StorageScan.sol:StorageScan",
          "label": "value",
          "offset": 0,
          "slot": "1",
          "type": "t_string_storage"
        }
      ],
      "numberOfBytes": "64"
    },

preference

https://github.com/yuhuajing/getSCSlotData/tree/main

https://github.com/yuhuajing/EVMSlotScan/tree/main

ParserUintSlot

数据能够在 EVM 栈空间连续存储的数据在 json storage 的编码格式: encoding:inplace

uint 类型没有负值,因此直接按照 类型长度和偏移量获取 slot 数据

enum 类型固定占位 8 bit,采用 uint8 类型表示

// enum  固定占 8 位 ,采用 uint8
type SolidityUint struct {
	SlotIndex common.Hash

	Length uint

	Offset uint
}
  1. 首先获取 baseSlot 的全部数值
  2. 根据偏移量和数据 length 类型判断是否独占一个 slot
  • 低位存在别的参数值的话,偏移量不为 0,直接右移去掉偏移值
  • 高位也可能存储别的参数值,因此要根据自身类型获取特定长度的值
    • 直接按照类型长度,将高位全置 0,length 长度的数据位置 1
    • 直接相与,去除高位数据
  • golang 数据仅支持到 int64
    • 数值超限的话,转为 string 输出
    • 数值不超限,直接输出
func (s SolidityUint) Value(f GetValueStorageAtFunc) interface{} {
	v := f(s.SlotIndex)
	vb := common.BytesToHash(v).Big()
	vb.Rsh(vb, s.Offset)

	mask := new(big.Int)
	mask.SetBit(mask, int(s.Length), 1).Sub(mask, big.NewInt(1))

	vb.And(vb, mask)

	// if vb > uint64 max, return string, else return uint64
	if vb.Cmp(big.NewInt(0).SetUint64(1<<64-1)) > 0 {
		return vb.String()
	} else {
		return vb.Uint64()
	}
}

ParserIntSlot

数据能够在 EVM 栈空间连续存储的数据在 json storage 的编码格式: encoding:inplace

int 类型存在负值,因此直接按照类型长度和偏移量获取 slot 数据后,还需要根据最高位的符号位判断正负

type SolidityInt struct {
	SlotIndex common.Hash
	Length    uint
	Offset    uint
}
  1. 首先获取 baseSlot 的全部数值
  2. 根据偏移量和数据 length 类型判断是否独占一个 slot
  • 低位存在别的参数值的话,偏移量不为 0,直接右移去掉偏移值
  • 高位也可能存储别的参数值,因此要根据自身类型获取特定长度的值
    • 直接按照类型长度,将高位全置 0,length 长度的数据位置 1
    • 直接相与,去除高位数据
  • 判断符号位:
    • 符号位为0(表示正数)
    • 符号位为1(表示负数)
  • 数值转为 string 输出
// Int 类型的数据按照顺序存储在slot中
// slot 栈宽不满256bit 时,数据放在同一个slot中存储

func (s SolidityInt) Value(f GetValueStorageAtFunc) interface{} {
	v := f(s.SlotIndex)
	// 获取当前slot的数据
	// 根据 length 和 offset 判断是否当前数据独占一个slot 还是和 别的数据共享 slot

	vb := common.BytesToHash(v).Big()
	vb.Rsh(vb, s.Offset) // 直接右移,去掉 offset的数据
	//下一步就是根据长度,去掉前面被挤占的数据

	// get mask for length
	mask := new(big.Int)
	mask.SetBit(mask, int(s.Length), 1).Sub(mask, big.NewInt(1))
	// 只保留 length 长度的 1,高位全是0

	// get value by mask
	vb.And(vb, mask)

	// Int类型的数据由符号
	// 通过最高位的符号位判断正负
	// signBit is 0 if the value is positive and 1 if it is negative
	signBit := new(big.Int)
	signBit.Rsh(vb, s.Length-1)
	if signBit.Uint64() == 0 {
		//return vb.Uint64()
		return vb.String()
	} else {
		//负数的处理
		// flip the bits
		vb.Sub(vb, big.NewInt(1))
		r := make([]byte, 0)
		for _, b := range vb.Bytes() {
			r = append(r, ^b)
		}
		// convert back to big int
		//return -new(big.Int).SetBytes(r).Int64()
		return "-" + new(big.Int).SetBytes(r).String()
	}
}

ParserBoolSlot

数据能够在 EVM 栈空间连续存储的数据在 json storage 的编码格式: encoding:inplace

bool 类型固定占位 8bit ,可以采用 uint8 类型或者 bool 类型解析

// bool  固定占 8 位
type SolidityBool struct {
	SlotIndex common.Hash

	Offset uint
}
  1. 首先获取 baseSlot 的全部数值
  2. 根据偏移量和数据 length == 8 类型判断是否独占一个 slot
  • 低位存在别的参数值的话,偏移量不为 0,直接右移去掉偏移值
  • 高位也可能存储别的参数值,因此要根据自身类型获取特定长度的值
    • 直接按照类型长度,将高位全置 0,length 长度的数据位置 1
    • 直接相与,去除高位数据
  • 数值转为 bool 类型输出
    • true = 1, false = 0
func (s SolidityBool) Value(f GetValueStorageAtFunc) interface{} {
	v := f(s.SlotIndex)
	vb := common.BytesToHash(v).Big()
	vb.Rsh(vb, s.Offset)

	lengthOffset := new(big.Int)
	lengthOffset.SetBit(lengthOffset, 8, 1).Sub(lengthOffset, big.NewInt(1))

	vb.And(vb, lengthOffset)
	return vb.Uint64() == 1
}

ParserAddressSlot

数据能够在 EVM 栈空间连续存储的数据在 json storage 的编码格式: encoding:inplace

address 类型固定占位 160bit

// address  固定占 160 位
type SolidityAddress struct {
	SlotIndex common.Hash

	Offset uint
}
  1. 首先获取 baseSlot 的全部数值
  2. 根据偏移量和数据 length == 160 类型判断是否独占一个 slot
  • 低位存在别的参数值的话,偏移量不为 0,直接右移去掉偏移值
  • 高位也可能存储别的参数值,因此要根据自身类型获取特定长度的值
    • 直接按照类型长度,将高位全置 0,length 长度的数据位置 1
    • 直接相与,去除高位数据
  • 数值转为地址类型输出
func (s SolidityAddress) Value(f GetValueStorageAtFunc) interface{} {
	v := f(s.SlotIndex)
	vb := common.BytesToHash(v).Big()
	vb.Rsh(vb, s.Offset)

	lengthOffset := new(big.Int)
	lengthOffset.SetBit(lengthOffset, 160, 1).Sub(lengthOffset, big.NewInt(1))

	vb.And(vb, lengthOffset)

	return common.BytesToAddress(vb.Bytes())
}

ParserBytesSlot

数据能够在 EVM 栈空间连续存储的数据在 json storage 的编码格式: encoding:inplace

bytes 类型表现为固定长度的数组,数据从 bytes1~bytes32, 有长度的区别

type SolidityBytes struct {
	SlotIndex common.Hash

	Length uint

	Offset uint
}
  1. 首先获取 baseSlot 的全部数值
  2. 根据偏移量和数据长度(bytes1~bytes32,即 8~256 bit)判断是否独占一个 slot
  • 低位存在别的参数值的话,偏移量不为 0,直接右移去掉偏移值
  • 高位也可能存储别的参数值,因此要根据自身类型获取特定长度的值
    • 直接按照类型长度,将高位全置 0,length 长度的数据位置 1
    • 直接相与,去除高位数据
  • 数值转为 string 类型输出
func (s SolidityBytes) Value(f GetValueStorageAtFunc) interface{} {
	v := f(s.SlotIndex)
	vb := common.BytesToHash(v).Big()
	vb.Rsh(vb, s.Offset)

	lengthOffset := new(big.Int)
	lengthOffset.SetBit(lengthOffset, int(s.Length), 1).Sub(lengthOffset, big.NewInt(1))

	vb.And(vb, lengthOffset)

	return string(common.TrimRightZeroes(vb.Bytes()))
}

ParserArraySlot

数据能够在 EVM 栈空间连续存储的数据在 json storage 的编码格式: encoding:inplace

array 类型为固定长度的数组,同时具备维度的区别

array 类型在 storage base 中表明 子集数据的类型

第一维数值表示当前数组子集的长度 最后维度表示当前数组的长度

uint[8], 一维数组,表示 8 个uint8类型的数据

uin8[2][3],二维数组,第一维的 2 表示每个数组子集的数据长度为2,最后维度 3 表示当前数组的长度是3
// [[1,2],[3,4],[5,6]]

数组的每个子集从新的slot开始编码存储数据

  • 因此,array 类型需要记录每维的数据长度
  • 第一维决定了子集参数数量
  • 第二维度决定了数据子集个数
type SolidityArray struct {
	SlotIndex common.Hash

	UnitLength uint64 `json:"unit_length"` // 第二维度的数据子集个数

	UnitTyp Variable `json:"unit_typ"` // 子集数据类型
}
  1. 首先获取当前数据类型
  • 一维数据就是当前数值的类型
  • 多维数据就是数组类型
  1. 根据数据类型获取slot数据
  • 一维数据类型,直接根据数据长度和偏移量获取数值
  • 多维数据
    • 首先根据baseSlot 和 子集参数类型以及子集数量 确定 数组占位
    • 逐层解析每一维度
      • 先获取当前维度中子集参数类型和子集个数,确定子集参数占据的slot长度
      • 按照子集类型,直接递归获取数值
func (s SolidityArray) Value(f GetValueStorageAtFunc) interface{} {
	switch s.UnitTyp.Typ() {
	case IntTy:
		si := s.UnitTyp.(*SolidityInt)
		return IntSliceValue{
			slotIndex:     s.SlotIndex,
			length:        s.UnitLength,
			uintBitLength: si.Length,
			f:             f,
		}
	case UintTy:
		su := s.UnitTyp.(*SolidityUint)
		return UintSliceValue{
			slotIndex:     s.SlotIndex,
			length:        s.UnitLength,
			uintBitLength: su.Length,
			f:             f,
		}
	case BytesTy:
		sb := s.UnitTyp.(*SolidityBytes)
		return BytesSliceValue{
			slotIndex:     s.SlotIndex,
			length:        s.UnitLength,
			uintBitLength: sb.Length,
			f:             f,
		}
	case StructTy:
		ss := s.UnitTyp.(*SolidityStruct)
		return StructSliceValue{
			slotIndex:     s.SlotIndex,
			length:        s.UnitLength,
			filedValueMap: ss.FiledValueMap,
			f:             f,
		}

	case BoolTy:
		return BoolSliceValue{
			length:    s.UnitLength,
			slotIndex: s.SlotIndex,
			f:         f,
		}
	case StringTy:
		return StringSliceValue{
			length:    s.UnitLength,
			slotIndex: s.SlotIndex,
			f:         f,
		}
	case AddressTy:
		return AddressSliceValue{
			length:    s.UnitLength,
			slotIndex: s.SlotIndex,
			f:         f,
		}
	case ArrayTy:
		lens := s.UnitLen()
		//fmt.Println(lens) // 第一层数组大小

		arrayLen := s.UnitTyp.(*SolidityArray).UnitLength
		//fmt.Println(arrayLen)

		dataTypeLen := s.UnitTyp.(*SolidityArray).UnitTyp.Len()
		//fmt.Println(dataTypeLen)

		var factor uint
		h := uint(arrayLen) * dataTypeLen % 256
		if h == 0 {
			factor += uint(arrayLen) * dataTypeLen / 256
		} else {
			factor += uint(arrayLen)*dataTypeLen/256 + 1
		}

		lens *= factor
		res := make([]interface{}, 0)
		for i := uint(0); i < lens; i++ {
			var loc int64 = 1
			if i == 0 {
				loc = 0
			}
			t := s.SlotIndex.Big().Int64() + loc
			sb := new(big.Int)
			sb.SetInt64(t)
			s.SlotIndex = common.BigToHash(sb)

			if i == 0 {
				s.UnitTyp = s.UnitTyp.(*SolidityArray).UnitTyp //uint8
				if dataTypeLen < 128 {
					s.UnitLength = uint64(dataTypeLen * uint(arrayLen))
				}
			}
			res = append(res, s.Value(f))
			//fmt.Println(s.Value(f))
		}
		return res
	}
	return nil
}

ParserStructSlot

数据能够在 EVM 栈空间连续存储的数据在 json storage 的编码格式: encoding:inplace

struct 内部参数按照声明顺序依次存储入栈

struct 在 storage members[] 中表明每个数据类型

    "t_struct(Entity)62_storage": {
      "encoding": "inplace",
      "label": "struct StorageScan.Entity",
      "members": [
        {
          "astId": 57,
          "contract": "StorageScan.sol:StorageScan",
          "label": "age",
          "offset": 0,
          "slot": "0",
          "type": "t_uint64"
        },
        {
          "astId": 59,
          "contract": "StorageScan.sol:StorageScan",
          "label": "id",
          "offset": 8,
          "slot": "0",
          "type": "t_uint128"
        },
        {
          "astId": 61,
          "contract": "StorageScan.sol:StorageScan",
          "label": "value",
          "offset": 0,
          "slot": "1",
          "type": "t_string_storage"
        }
      ],
      "numberOfBytes": "64"
    }

结构体中每个变量都是独立的数据类型,因此,结构体通过 Field 字段定义每个变量的数据类型

type StructValueI interface {
	Field(f string) interface{}
	String() string
}
  1. 首先获取当前 Field 的数据类型
  2. 获取结构体内部变量的每个起始 slot 存储位置,基于 baseSlot 和 声明顺序
  3. 按照每个变量类型和 slot 起始位置获取具体的数值
func (s StructValue) Field(fd string) interface{} {
	filedValue, ok := s.filedValueMap[fd]
	if !ok {
		return nil
	}

	oldSlot := filedValue.Slot()

	slotIndex := new(big.Int)
	slotIndex.Add(s.baseSlotIndex.Big(), filedValue.Slot().Big())

	// convert the slotIndex to common.Hash and assign it to the SlotIndex field of filed Value.V, using reflection
	reflect.ValueOf(filedValue).Elem().FieldByName("SlotIndex").Set(reflect.ValueOf(common.BigToHash(slotIndex)))
	value := filedValue.Value(s.f)
	reflect.ValueOf(filedValue).Elem().FieldByName("SlotIndex").Set(reflect.ValueOf(oldSlot))
	return value
}

ParserDynamicArraySlot

动态数组数据在 json storage 的编码格式: encoding:dynamic_array

array 类型在 storage base 中表明子集数据的类型

数组的每个子集参数基于当前的 baseSlot 和 参数顺序 存储数据

type SoliditySlice struct {
	SlotIndex common.Hash

	UnitTyp Variable `json:"unit_typ"`
}
  1. 首先获取当前参数的 baseSlot
  2. 获取当前数据类型,根据数据类型获取slot数据
func (s SoliditySlice) Value(f GetValueStorageAtFunc) interface{} {
	length := common.BytesToHash(f(s.SlotIndex)).Big().Uint64()
	valueSlotIndex := crypto.Keccak256Hash(s.SlotIndex.Bytes())

	switch s.UnitTyp.Typ() {
	case IntTy:
		si := s.UnitTyp.(*SolidityInt)
		return IntSliceValue{
			slotIndex:     valueSlotIndex,
			length:        length,
			uintBitLength: si.Length,
			f:             f,
		}
	case UintTy:
		su := s.UnitTyp.(*SolidityUint)
		return UintSliceValue{
			slotIndex:     valueSlotIndex,
			length:        length,
			uintBitLength: su.Length,
			f:             f,
		}
	case BytesTy:
		sb := s.UnitTyp.(*SolidityBytes)
		return BytesSliceValue{
			slotIndex:     valueSlotIndex,
			length:        length,
			uintBitLength: sb.Length,
			f:             f,
		}
	case StructTy:
		ss := s.UnitTyp.(*SolidityStruct)
		return StructSliceValue{
			slotIndex:     valueSlotIndex,
			length:        length,
			filedValueMap: ss.FiledValueMap,
			f:             f,
		}

	case BoolTy:
		return BoolSliceValue{
			slotIndex: valueSlotIndex,
			length:    length,
			f:         f,
		}
	case StringTy:
		return StringSliceValue{
			slotIndex: valueSlotIndex,
			length:    length,
			f:         f,
		}
	case AddressTy:
		return AddressSliceValue{
			slotIndex: valueSlotIndex,
			length:    length,
			f:         f,
		}
	case SliceTy:
		{
			ss := s.UnitTyp.(*SoliditySlice)
			return ss.Value(f)
		}

	}
	return nil

}

ParserDynamicStringSlot

动态数组数据在 json storage 的编码格式: encoding:bytes

  1. 首先获取当前参数的 baseSlot
  2. 查询 baseSlot 中存储的值
  • 根据末尾数值判断 string 数据存储在当前 slot 或者仅存储了当前 string 数据的长度
    • 末尾数值为 0,表示短 string
      • 数据存储在当前 slot, 数据编码格式为 string + length
    • 末尾数值为 1,表示长 string
      • 数据长度存储在当前 slot, 数据编码格式为 len(string) + length
      • 具体数据从 keccak256(slot) 开始存储
        • 数据长度取余 256 为 0 的话,数据存储在整数个 slot,直接相除获取具体 slot 个数
        • 取余为 1 的话,表示有额外 slot
  • length 占位 8 bit
  1. 获取当前数据类型,根据数据类型获取 slot 数据
// Value calculate the string length of the current slot record
// the length of the string exceeds 31 bytes (0x1f), and the entire slot stores the length of the string*2+1
// the length of the string does not exceed 31 bytes, the rightmost bit of the entire slot stores the character length*2, and the leftmost stores the string content
// if the last digit is odd then it is a long string, otherwise it is a short  string
func (s SolidityString) Value(f GetValueStorageAtFunc) interface{} {
	data := f(s.SlotIndex)
	v := common.BytesToHash(data).Big()

	// get the last digit of v
	lastDigit := v.Bit(0)

	//  equal to 1 means it is a long string
	if lastDigit == 1 {
		// get the current string length bit
		length := new(big.Int)
		length.Sub(v, big.NewInt(1)).Div(length, big.NewInt(2)).Mul(length, big.NewInt(8))

		remainB := new(big.Int)
		remainB.Mod(length, big.NewInt(256))

		slotNum := new(big.Int)
		if remainB.Uint64() == 0 {
			slotNum.Div(length, big.NewInt(256))
		} else {
			slotNum.Div(length, big.NewInt(256)).Add(slotNum, big.NewInt(1))
		}

		firstSlotIndex := crypto.Keccak256Hash(s.SlotIndex.Bytes())

		value := f(firstSlotIndex)

		for i := int64(0); i < slotNum.Int64()-1; i++ {
			nextSlot := new(big.Int)
			nextSlot.Add(firstSlotIndex.Big(), big.NewInt(i))
			nextValue := f(common.BigToHash(nextSlot))
			value = append(value, nextValue...)
		}

		lastSlotIndex := new(big.Int)
		lastSlotIndex.Add(firstSlotIndex.Big(), big.NewInt(slotNum.Int64()-1))

		lastSlotValue := f(common.BigToHash(lastSlotIndex))

		if remainB.Uint64() == 0 {
			value = append(value, lastSlotValue...)
		} else {
			// move right to get the final value
			lastValueBig := common.BytesToHash(lastSlotValue).Big()
			lastValueBig.Rsh(lastValueBig, 256-uint(remainB.Uint64()))
			value = append(value, lastValueBig.Bytes()...)
		}

		return string(value)
	} else {

		length := new(big.Int)
		length.And(v, big.NewInt(0xff))
		length.Div(length, big.NewInt(2)).Mul(length, big.NewInt(8))

		v.Rsh(v, 256-uint(length.Uint64()))

		return string(v.Bytes())
	}
}

ParserMappingSlot

数据在 json storage 的编码格式: encoding:mapping

mapping 类型通过 key,value 表明数据的类型

    "t_mapping(t_int256,t_uint256)": {
      "encoding": "mapping",
      "key": "t_int256",
      "label": "mapping(int256 => uint256)",
      "numberOfBytes": "32",
      "value": "t_uint256"
    }

mapping 数据的具体存储位置和 mapping 键值和层数相关

  • 因此,mapping 类型需要记录当前数据的 baseSlot
  • 每层键值决定了下一层数据的起始 slot
  • 键值类型决定了键值的编码格式
type SolidityMapping struct {
	SlotIndex common.Hash

	KeyTyp SolidityTyp

	ValueTyp Variable `json:"value_typ"`
}
  1. 根据键值传参判断当前 mapping 的层数
  2. 计算最外层数据的起始 slot 存储位置
  3. 每进入一层,就进入新的 mapping 数据解析过程
  • 重新计算当前层的数据起始存储 slot
  • 根据数据类型获取具体的数值
// slotIndex = abi.encode(key,slot)
func (m MappingValue) Keys(ks []string) interface{} {
	var slotIndex = m.baseSlotIndex
	//k := ks[0]
	var keyByte []byte
	for index, k := range ks {
		if index != 0 && index+1 <= len(ks) {
			m.keyTyp = m.valueTyp.(*SolidityMapping).KeyTyp
			m.valueTyp = m.valueTyp.(*SolidityMapping).ValueTyp
		}
		switch m.keyTyp {
		case UintTy:
			keyByte = encodeUintString(k)
		case IntTy:
			keyByte = encodeIntString(k)
		case BytesTy:
			keyByte = encodeByteString(k)
		case StringTy:
			keyByte = []byte(k)
		case AddressTy:
			keyByte = encodeHexString(k)
		default:
			panic("invalid key type")
		}
		slotIndex = crypto.Keccak256Hash(keyByte, slotIndex.Bytes())
	}

	reflect.ValueOf(m.valueTyp).Elem().FieldByName("SlotIndex").Set(reflect.ValueOf(slotIndex))
	return m.valueTyp.Value(m.f)
}

MerkleProof

简介链接:

https://yuhuajing.github.io/solidity-book/milestone_6/merkle-proof-validation.html

https://github.com/yuhuajing/solidity-book/tree/main/src/ContractsHub/merkle_tree_prove

merkleSecurity

为保证默克尔树的安全性,防止攻击者仅传递非叶子节点的数据跳过校验

叶子节点和验证节点的采用不同的 hash 校验:

叶子节点双 hash

待验证的叶子节点采用双 hash 的方式,区别叶子节点和验证节点的数据

func abiPackLeafHash(leafEncodings []string, values ...interface{}) ([]byte, error) {
	data, err := AbiPack(leafEncodings, values...)
	if err != nil {
		return nil, err
	}
	hash, err := standardLeafHash(data)
	return hash, err
}

func standardLeafHash(value []byte) ([]byte, error) {
	k1, err := Keccak256(value)
	if err != nil {
		return nil, err
	}
	k2, err := Keccak256(k1)
	return k2, err
}

单类型叶子节点

叶子节点仅采用单一数据类型,匹配的验证合约能够获取的数据有限

func MerkleOnlyOneArgOZ() {
	leaf1 := []interface{}{
		smt.SolAddress("0x0000000000000000000000000000000000000000"),
	}

	leaf2 := []interface{}{
		smt.SolAddress("0x0000000000000000000000000000000000000000"),
	}

	leaf3 := []interface{}{
		smt.SolAddress("0x0000000000000000000000000000000000000000"),
	}

	leaf4 := []interface{}{
		smt.SolAddress("0x0000000000000000000000000000000000000000"),
	}
	leaf5 := []interface{}{
		smt.SolAddress("0x0000000000000000000000000000000000000000"),
	}

	leaves := [][]interface{}{
		leaf1,
		leaf2,
		leaf3,
		leaf4,
		leaf5,
	}

	tree, err := smt.Of(
		leaves,
		[]string{
			smt.SOL_ADDRESS,
			//smt.SOL_UINT256,
		})

	if err != nil {
		fmt.Println("Of ERR", err)
	}

	root := hexutil.Encode(tree.GetRoot())
	fmt.Println("Merkle Root: ", root)

	proof, err := tree.GetProof(leaf1)
	strProof := make([]string, 0, len(proof))
	if err != nil {
		fmt.Println("GetProof ERR", err)
	}
	for _, v := range proof {
		strProof = append(strProof, hexutil.Encode(v))
	}
	fmt.Println("02 proof: ", strProof)
}

多类型叶子节点

叶子节点拼接数据类型,可以将各种类型拼接(各种类型、类型数组等),匹配的验证合约能够获取足额数据,进行额外数据的处理

示例代码将地址和数量拼接,合约中通过额外的 mapping 记录,可以验证当前地址的剩余额度,

func MerkleWithMultiArgOZ() {
	leaf1 := []interface{}{
		smt.SolAddress("0x0000000000000000000000000000000000000000"),
		smt.SolNumber("5000000000000000000"),
	}

	leaf2 := []interface{}{
		smt.SolAddress("0x0000000000000000000000000000000000000000"),
		smt.SolNumber("2500000000000000000"),
	}

	leaf3 := []interface{}{
		smt.SolAddress("0x0000000000000000000000000000000000000000"),
		smt.SolNumber("5000000000000000000"),
	}

	leaf4 := []interface{}{
		smt.SolAddress("0x0000000000000000000000000000000000000000"),
		smt.SolNumber("2500000000000000000"),
	}
	leaf5 := []interface{}{
		smt.SolAddress("0x0000000000000000000000000000000000000000"),
		smt.SolNumber("2500000000000000000"),
	}

	leaves := [][]interface{}{
		leaf1,
		leaf2,
		leaf3,
		leaf4,
		leaf5,
	}

	tree, err := smt.Of(
		leaves,
		[]string{
			smt.SOL_ADDRESS,
			smt.SOL_UINT256,
		})

	if err != nil {
		fmt.Println("Of ERR", err)
	}

	root := hexutil.Encode(tree.GetRoot())
	fmt.Println("Merkle Root: ", root)

	proof, err := tree.GetProof(leaf1)
	strProof := make([]string, 0, len(proof))
	if err != nil {
		fmt.Println("GetProof ERR", err)
	}
	for _, v := range proof {
		strProof = append(strProof, hexutil.Encode(v))
	}
	fmt.Println("02 proof: ", strProof)
}

Signature

简介:

https://yuhuajing.github.io/solidity-book/milestone_6/signature-ECDSA-validation.html

sign_sha256

预编译合约地址 0x2 https://yuhuajing.github.io/solidity-book/milestone_5/contracts-precompile.html实现 sha256 哈希校验

package sign

import (
	"crypto/sha256"
	"fmt"
	"github.com/ethereum/go-ethereum/common/hexutil"
	"math/big"
)

func Sha256EncodeNumber(number int64) {
	s := hexutil.EncodeBig(big.NewInt(number))
	prefix := ""
	num := 64 - len(s[2:])
	for index := 0; index < num; index++ {
		prefix += "0"
	}
	s = s[:2] + prefix + s[2:]
	byteD, err := hexutil.Decode(s)
	if err != nil {
		fmt.Println(err)
	}
	h := sha256.New()
	h.Write(byteD)
	bs := h.Sum(nil)
	fmt.Println(hexutil.Encode(bs))
}

sign_ecdsa

  1. 编码 hash 待签名数据
	prefix := []byte(fmt.Sprintf("\x19Ethereum Signed Message:\n%d", len(message)))
	messageBytes := milestone4.UnsafeBytes(message)

	// Hash the prefix and message using Keccak-256
	hash := crypto.Keccak256Hash(prefix, messageBytes)

编码多种类型的代签数据,基于开源库solsha3 "github.com/miguelmota/go-solidity-sha3"

	mes := solsha3.SoliditySHA3(
		[]string{"uint32[]", "uint32", "uint64", "uint64", "uint64", "address", "address", "address"},
		[]interface{}{
			nftId,
			chainId,
			timestamp,
			uuid,
			signId,
			nft,
			sender,
			contract,
		},
	)
	hash := solsha3.SoliditySHA3WithPrefix(mes)
  1. 私钥签名数据hash
	// Sign the hashed message
	sig, err := crypto.Sign(hash.Bytes(), ecdsaPrivateKey)
	if err != nil {
		log.Fatalln(err)
	}

	// Adjust signature ID to Ethereum's format
	sig[64] += 27
  1. 校验数据,根据待签数据和签名逆向反推签名地址
  • 签名不匹配的话,会返回错误的签名地址
func VerifySig(signature, address, message string) bool {
	// Decode the signature into bytes
	sig, err := hexutil.Decode(signature)
	if err != nil {
		log.Fatalln(err)
	}

	// Adjust signature to standard format (remove Ethereum's recovery ID)
	sig[64] = sig[64] - 27

	// Construct the message prefix
	prefix := []byte(fmt.Sprintf("\x19Ethereum Signed Message:\n%d", len(message)))
	data := []byte(message)

	// Hash the prefix and data using Keccak-256
	hash := crypto.Keccak256Hash(prefix, data)

	// Recover the public key bytes from the signature
	sigPublicKeyBytes, err := crypto.Ecrecover(hash.Bytes(), sig)
	if err != nil {
		log.Fatalln(err)
	}
	ecdsaPublicKey, err := crypto.UnmarshalPubkey(sigPublicKeyBytes)
	if err != nil {
		log.Fatalln(err)
	}

	// Derive the address from the recovered public key
	rAddress := crypto.PubkeyToAddress(*ecdsaPublicKey)

	// Check if the recovered address matches the provided address
	isSigner := strings.EqualFold(rAddress.String(), address)

	return isSigner
}

Ethereum 验证签名(EOA + EIP-1271)

背景:两种「签名者」

在 Ethereum 上,「谁能对一段消息给出签名」分两种账户:

  1. EOA(外部账户):由私钥控制,标准 ECDSA 签名,可用公钥反推地址。本仓库 signature.md 已讲过这种的签名与验签。
  2. 合约账户(如 Gnosis Safe):没有私钥。它「签名」的方式是实现一个合约函数 isValidSignature(hash, signature) → bytes4,由合约内部逻辑决定这个 hash 是否被认可。标准叫 EIP-1271。

前端传来一个 (address, message, signature) 三元组时,你事先并不知道对方是 EOA 还是合约钱包。因此健壮的验签要两条路都走:

  • 先按 EOA 规则反推地址,看是否等于 address;
  • 不等(或签名格式不像 EOA)时,再按 EIP-1271 去链上问那个 address 合约:「这个签名你认吗?」

下面这套代码就是这条「EOA 优先、EIP-1271 兜底」的链路。

EIP-1271 部分:去链上问合约

const eip1271ABI = `[{
    "inputs": [
        {"type": "bytes32", "name": "_hash"},
        {"type": "bytes", "name": "_signature"}
    ],
    "name": "isValidSignature",
    "outputs": [
        {"type": "bytes4", "name": "magicValue"}
    ],
    "stateMutability": "view",
    "type": "function"
}]`

// eip1271MagicValue 是 isValidSignature 成功时应返回的魔术值, 移除了 "0x" 前缀
const eip1271MagicValue = "1626ba7e"

func VerifySafeSignature(safeAddress string, message string, signature string) (bool, error) {
	// 连接以太坊节点
	client, err := ethclient.Dial("https://intensive-boldest-sailboat.quiknode.pro/xx/")
	if err != nil {
		return false, fmt.Errorf("failed to connect to Ethereum node: %v", err)
	}
	defer client.Close()

	// 将字符串地址转换为 common.Address
	contractAddress := common.HexToAddress(safeAddress)

	// 获取地址对应的代码
	code, err := client.CodeAt(context.Background(), contractAddress, nil)
	if err != nil {
		return false, fmt.Errorf("failed to get code at address: %v", err)
	}

	// 如果没有代码,则不是合约,因此不是 Safe 钱包
	if len(code) == 0 {
		return false, nil
	}

	// 解析 ABI
	parsedABI, err := abi.JSON(strings.NewReader(eip1271ABI))
	if err != nil {
		return false, fmt.Errorf("failed to parse ABI: %v", err)
	}

	// 获取消息哈希(accounts.TextHash 会自动加上 "\x19Ethereum Signed Message:\n" 前缀)
	messageHash := accounts.TextHash(utils.UnsafeBytes(message))
	var messageHash32 [32]byte
	copy(messageHash32[:], messageHash)

	// 打包调用数据
	packedData, err := parsedABI.Pack("isValidSignature", messageHash32, common.FromHex(signature))
	if err != nil {
		return false, fmt.Errorf("failed to pack data: %v", err)
	}

	// 调用合约(view 调用,不消耗 gas,只读)
	result, err := client.CallContract(context.Background(), ethereum.CallMsg{
		To:   &contractAddress,
		Data: packedData,
	}, nil)
	if err != nil {
		return false, fmt.Errorf("contract call failed: %v", err)
	}

	// 检查返回的魔术值
	if len(result) < 4 {
		return false, fmt.Errorf("insufficient return value length")
	}

	// 将返回值转换为十六进制字符串,比较魔术值是否匹配
	return common.Bytes2Hex(result[:4]) == eip1271MagicValue, nil
}

逐段拆解:

  • 先 CodeAt 探一下有没有合约代码。地址里没代码的一定是 EOA,不可能实现 EIP-1271,直接返回 false(不是错误,只是「这条合约验签路不通」)。
  • accounts.TextHash 是关键:它内部帮你拼好 Ethereum 的消息前缀 \x19Ethereum Signed Message:\n<len>,再做 Keccak256。也就是说验签用的 hash 必须和签名端用的 hash 完全一致——如果前端是用 ethers.signMessage(text) 签的,它也是加同样前缀再 hash,这里用 TextHash 就对得上(对照本仓库 signature.md 里手动拼 prefix 的写法,这里省了手工活)。
  • abi.Pack("isValidSignature", hash32, sig):把函数调用编码成合约 calldata。isValidSignature 入参是 bytes32 hash + bytes signature,出参 bytes4 magicValue。
  • CallContract 是只读 view 调用,不花 gas、不需要签名者私钥,任何人都能查——这正是 EIP-1271 验签的本质:把「认不认这个签名」的判定权交给合约自己。
  • 判定看返回值前 4 字节:EIP-1271 规定「认可」时返回魔术值 0x1626ba7e。代码里 eip1271MagicValue 去掉了 0x 前缀的 1626ba7e,与 common.Bytes2Hex(result[:4]) 直接比字符串。

注意 ethclient.Dial 的 URL 这里是个占位(.../xx/)。真实项目应像发邮件章节那样从配置读取 RPC 地址,不要硬编码进仓库,更不要共用一个不知来源的公开节点。

EOA 部分:本地反推地址

func Verify(address string, message string, signature string) bool {
	// 消息通常会被哈希化后再被签名
	hashed := accounts.TextHash(utils.UnsafeBytes(message))
	sig := common.FromHex(signature)
	if len(sig) != crypto.SignatureLength { // 因为签名应该是65字节长: r (32 bytes) + s (32 bytes) + v (1 byte)
		isValid, err := VerifySafeSignature(address, message, signature)
		if err == nil && isValid {
			return true
		}

		log.Error().Msg("Incorrect signature length")
		return false
	}

	if sig[crypto.RecoveryIDOffset] != 27 && sig[crypto.RecoveryIDOffset] != 28 {
		log.Error().Msg("Invalid Ethereum signature (V is not 27 or 28)")
		return false
	}

	sig[crypto.RecoveryIDOffset] -= 27 // 转换V为0或1

	// 使用签名来恢复公钥
	pubKey, err := crypto.SigToPub(hashed, sig)
	if err != nil {
		log.Error().Err(err).Msg("Unable to recover public key from signature")
		return false
	}

	sigPubBytes := crypto.FromECDSAPub(pubKey)

	signatureNoRecoverID := sig[:len(sig)-1] // remove recovery id
	verified := crypto.VerifySignature(sigPubBytes, hashed, signatureNoRecoverID)
	if !verified {
		log.Error().Msg("Invalid Ethereum signature")
		return false
	}

	recoveredAddress := crypto.PubkeyToAddress(*pubKey)

	// 检查恢复的地址是否与已知的地址匹配
	// 这里需要您有该地址或者与前端收集的地址进行比对
	expectedAddress := common.HexToAddress(address)

	if strings.ToLower(recoveredAddress.Hex()) == strings.ToLower(expectedAddress.Hex()) {
		return true
	}

	// 如果上面的验证失败,尝试通过 Safe 合约的 isValidSignature 验证
	isValid, err := VerifySafeSignature(address, message, signature)
	if err != nil {
		log.Error().Err(err).Msg("Safe signature verification failed")
		return false
	}

	return isValid
}

逐段拆解:

  • crypto.SignatureLength = 65:标准 ECDSA 签名是 r(32) + s(32) + v(1)。若长度不是 65,基本不可能是裸 EOA 签名,代码直接转去走 EIP-1271 合约验签。
  • v 的两种取值:Ethereum 签名的 recovery id 常写成 27 或 28(「比特币式」),而 go-ethereum 的 crypto.SigToPub 期望 0 或 1,所以 sig[RecoveryIDOffset] -= 27 做一次归一化。
  • crypto.SigToPub(hashed, sig):用 (hash, sig) 反推出签名者的公钥——这就是 ECDSA 的「从签名恢复公钥」。
  • crypto.VerifySignature(pubBytes, hash, sigNoRecoverID):用恢复出的公钥再验一次签名完整性(入参是去掉 recovery id 的 64 字节 r||s)。这一步是「公钥对得上、签名本身没被篡改」的双重保险。
  • 核心判定:recoveredAddress == expectedAddress。即「用这个签名反推出来的地址,是不是前端声称的那个地址」。相等 → 证明前端确实持有该地址的私钥(或得到了其授权)。
  • 兜底:EOA 反推地址不匹配时,再调一次 VerifySafeSignature 去链上问合约。于是同一个入口 Verify 同时支持两类钱包。

调用流程总览

Verify(address, message, signature)
   │
   ├─ sig 长度 != 65 ? ──► VerifySafeSignature(合约验签)
   │
   ├─ sig 长度 == 65
   │     ├─ v 不是 27/28 ? ──► 失败
   │     ├─ SigToPub + VerifySignature ──► 反推地址
   │     ├─ 反推地址 == 预期地址 ? ──► ✅ 成功(EOA)
   │     └─ 不等 ? ──► VerifySafeSignature(合约验签兜底)
   │
   └─ 任一路径返回 true 即验签通过

几个必须注意的坑

  1. hash 前缀必须前后端一致。这里用 accounts.TextHash(带 \x19Ethereum Signed Message: 前缀)。前端若用 signTypedData(EIP-712)而不是 signMessage,hash 算法完全不同,这份代码验不过——需要另写 EIP-712 的 hash 构造。验签前务必和后端的签名端、前端的签名端对齐「消息是怎么 hash 的」。

  2. EIP-1271 是「信任合约判定的」。合约说认,就认。这意味着安全性完全依赖那个合约地址的实现——必须是你信任的 Safe 部署地址,不能是用户随便传来的任意合约。生产环境应把受认可的合约地址白名单化,而不是对任意 address 都去调 isValidSignature(否则用户可部署一个「永远返回 true」的恶意合约来伪造验签)。

  3. 每调用一次 VerifySafeSignature 就 ethclient.Dial 建一个新连接。高并发场景应复用 ethclient.Client(全局单例 / 连接池),避免频繁建连。

  4. utils.UnsafeBytes 来自 gofiber,只是 string → []byte 的零拷贝转换;不引 gofiber 时换成 []byte(message) 即可。

  5. CallContract 的区块参数传 nil 表示用最新区块。若你需要「在某个历史时刻该签名是否有效」(如链上多签阈值曾变化),应传入具体 blockNumber。

与 signature.md 的关系

  • signature.md:讲怎么签(手动拼 \x19Ethereum Signed Message: 前缀 → Keccak256 → crypto.Sign → v += 27),以及最基础的 EOA 验签。
  • 本章:讲怎么验得更稳——在 EOA 验签基础上,补上 EIP-1271 合约钱包的链上验签兜底,覆盖「用户用 Safe 等智能合约钱包登录」的真实场景。

两者验签用的 hash 算法一致(TextHash 等价于 signature.md 里手拼的 prefix + Keccak256),可对照阅读。

PrivatePOAEthereum

正式使用硬件需求

Hardware Requirements Minimum:

CPU with 2+ cores 4GB RAM 1TB free storage space to sync the Mainnet 8 MBit/sec download Internet service Recommended:

Fast CPU with 4+ cores 16GB+ RAM High-performance SSD with at least 1TB of free space 25+ MBit/sec download Internet service

出块节点: 开放30303 (测试可以开放8545,后续需要关闭) 同步节点:开放30303 8545

安装golang gcc

sudo apt install build-essential

两种方式编译Geth工具

1- 从源码编译

git clone https://github.com/ethereum/go-ethereum.git

编译geth工具

 cd go-ethereum && make geth

2- 直接下载geth工具

wget https://gethstore.blob.core.windows.net/builds/geth-linux-amd64-1.12.1-9c216bd6.tar.gz

解压文件夹

tar -xzf geth-linux-amd64-1.12.1-9c216bd6.tar.gz

写入geth环境变量

vi ~/.bashrc
export ETHPATH=YouPathToGeth
export PATH=$ETHPATH:$PATH
source ~/.bashrc

生成账户地址

私钥通过用户输入的密码加密存储,可以通过golang解析出账户地址和私钥:

geth account new --datadir /opt/etherData

Address: 0x430CbEEffa18BD7ad0Ae5BAc062f130b6c8129B6

geth account new --datadir /opt/etherData

Address: 0x413E129dD6b217E4a8702821ee069e1929D17c6a

geth account new --datadir /opt/etherData

Address: 0x9449202f3E28Dd4595b0BE3c1736922Ba5aAce71

构造创世区块

1. chainID:自定义的链ID
2. homesteadBlock、eip150Block、eip155Block、eip158Block、byzantiumBlock、constantinopleBlock、petersburgBlock:各项提案和升级的区块高度
3. period:出块时间间隔,0为不允许出空交易块,会等待有交易才出块
4. epoch:更新出块节点列表的周期
5. difficulty:POA下无作用
6. gasLimit:gasLimit限制
7. extradata:POA模式下用来指定验证者地址,账户地址去掉0x后加上64个前缀0和130个后缀0,比如0x+(64个前缀0)+5534F5024146D16a5C1ce60A9f5a2e9794e3F981+(130个后缀0)
8. alloc:用来预置账号以及账号的以太币数量,比如预置0x0B587FFD0BBa122fb5ddc19AD6eEcEB1D2dBbff7地址拥有1000ETH(1000*10^18WEI)

genesis.json

{
   "config":{
      "chainId":12345,
      "homesteadBlock":0,
      "eip150Block":0,
      "eip155Block":0,
      "eip158Block":0,
      "byzantiumBlock":0,
      "constantinopleBlock":0,
      "petersburgBlock":0,
      "istanbulBlock":0,
      "berlinBlock":0,
      "londonBlock": 0,
      "clique":{
         "period":5,
         "epoch":300
      }
   },
   "alloc":{
      "0x6593B47be3F4Bd1154c2faFb8Ad4aC4EFddD618f":{
         "balance":"1000000000000000000000"
      },
      "0x6C345f0771a2f2B2694f97522D3371bF87b6BDF9":{
         "balance":"1000000000000000000000"
      },
      "0xab6bbb89eFd62dF605C881E692960a4951238D71":{
         "balance":"1000000000000000000000"
      }
   },
  "coinbase": "0x6593B47be3F4Bd1154c2faFb8Ad4aC4EFddD618f",
  "difficulty": "1",
  "extraData": "0x0000000000000000000000000000000000000000000000000000000000000000验证者地址0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
  "gasLimit": "80000000",
  "nonce": "0x0000000000000000",
  "mixhash": "0x0000000000000000000000000000000000000000000000000000000000000000",
  "parentHash": "0x0000000000000000000000000000000000000000000000000000000000000000",
  "timestamp": "0x00"
}

启动节点

创建创世区块

geth --datadir /opt/etherData init ./genesis.json

节点启动

节点正常启动

nohup geth --identity "myethereum" --datadir /opt/etherData --allow-insecure-unlock --networkid 12345 --http --http.addr 0.0.0.0  --http.corsdomain "*" --ws --ws.addr 0.0.0.0 --ws.origins "*"  --http.api "eth,net,debug,txpool,web3,personal,admin,miner"  --rpc.enabledeprecatedpersonal --miner.gaslimit 80000000 --syncmode "full" --nodiscover --rpc.enabledeprecatedpersonal >> geth.log 2>&1 &

连接控制台

   geth attach http://localhost:8545

或者ipc连接

     geth attach /opt/etherData/node0/geth.ipc

查询链上状态

  1. 查看所有账户列表
eth.accounts
  1. 根据节点私钥导入账号,提供节点私钥、加密节点私钥的对称密钥
personal.importRawKey("","")
  1. 查看账户余额
eth.getBalance(eth.accounts[0])
balance=web3.fromWei(eth.getBalance(eth.accounts[0]),'ether')
  1. 查询区块高度
eth.blockNumber
  1. 根据交易hash查询交易数据
eth.getTransaction("TxHash")
  1. 查看节点信息
admin.nodeInfo.enode

发送链上交易

  1. 发送链上交易时,需要先解锁账户。
eth.sendTransaction({from:eth.accounts[0],to:eth.accounts[1],value:web3.toWei(4,'ether')})
  1. 解锁账户--需要指定时间,默认解锁300s personal.unlockAccount(address, passphrase, duration),密码和解锁时长都是可选的。如果密码为null,控制台将提示交互输密码。解密的密钥将保存在内存中直到解锁周期超时,默认的解锁周期为300秒。将解锁周期设置为0秒将解锁该密钥直到退出geth程序。
personal.unlockAccount(eth.accounts[0],'password',0)

启动出块

  1. 设置矿工地址(验证者地址)
miner.setEtherbase(eth.accounts[0])
  1. 查看矿工账户
eth.coinbase
  1. 设置区块GasLimit
miner.setGasLimit(80000000)
  1. 启动出块(start() 的参数表示出块使用的线程数)/关闭出块 必须先解锁矿工账户,否则不会启动出块
miner.start()
miner.stop()

关闭节点

ps aux | grep geth | grep -v grep | awk '{print $2}'| xargs kill -15

清除链数据

geth removedb --datadir "/opt/etherData/"

启动节点2

新机器操作

数据备份节点

  1. 拷贝 genesis.json,创建相同的0号区块
  2. 节点正常启动
nohup geth --identity "myethereum" --datadir /opt/etherData --allow-insecure-unlock --networkid 12345 --http --http.addr 0.0.0.0  --http.corsdomain "*" --ws --ws.addr 0.0.0.0 --ws.origins "*"  --http.api "eth,net,debug,txpool,web3,personal,admin,miner"  --rpc.enabledeprecatedpersonal --miner.gaslimit 80000000 --syncmode "full" --nodiscover --rpc.enabledeprecatedpersonal >> geth.log 2>&1 &

连接控制台

   geth attach http://localhost:8545

或者ipc连接

     geth attach /opt/etherData/node0/geth.ipc
  1. 建立连接 查询节点信息
admin.nodeInfo.enode

通过addPeer命令添加节点.

admin.addPeer("节点信息")

出块节点(新的验证者地址)

新机器执行以下操作

  1. 生成节点
geth account new --datadir /opt/etherData

Address: 0x68d866baAfa993bc002cd35218c13f10aC54221d

  1. 连接控制台
  2. 在已连接的节点上执行以下操作:(需要半数以上的验证者同意)

目前的验证节点通过发起提案增加出块节点,增加后的节点和当前的验证者轮流出块。

clique.propose("新机器上生成的新验证者地址",true)

回到新服务器的终端:

  1. 在新服务器上设置矿工地址(新的验证者地址)
miner.setEtherbase(eth.accounts[0])
  1. 查看矿工账户
eth.coinbase
  1. 解锁账户
personal.unlockAccount(eth.accounts[0],'password',0)
  1. 启动挖矿(start() 的参数表示挖矿使用的线程数)/关闭挖矿
miner.start()

连接钱包

钱包中创建新的网络

当前服务器已经启动区块链,对外开放 8545 接口

通过 ip:8545 查询链上信息,指定链Id唯一标识链身份

导出节点私钥

通过 geth account new 创建的地址保存在 keystore 文件夹中

通过 keystore 和密码可以解密导出私钥数据

package main

import (
	"encoding/hex"
	"flag"
	"fmt"
	"os"

	"github.com/ethereum/go-ethereum/accounts/keystore"
	"github.com/ethereum/go-ethereum/crypto"
)

var (
	file     = flag.String("file", "", "file")
	password = flag.String("password", "", "password")
)

func init() {
	flag.Parse()
}

func main() {
	if _, err := os.Stat(*file); os.IsNotExist(err) {
		flag.Usage()
		os.Exit(1)
	}

	keyjson, err := os.ReadFile(*file)
	if err != nil {
		panic(err)
	}

	key, err := keystore.DecryptKey(keyjson, *password)
	if err != nil {
		panic(err)
	}

	address := key.Address.Hex()
	privateKey := hex.EncodeToString(crypto.FromECDSA(key.PrivateKey))

	fmt.Printf("Address: %s\nPrivateKey: %s\n",
		address,
		privateKey,
	)
}

运行示例:

go run main.go --file /opt/etherAccount/node1/keystore/UTC--2023-07-18T06-10-46.187084661Z--0b587ffd0bba122fb5ddc19ad6eeceb1d2dbbff7 --password xxxx