这是我参与「第五届青训营 」伴学笔记创作活动的第 1 天
Go 语言入门 课堂笔记
1 本次课堂的内容
这节课首先从 Go 语言基本特征、应用优势等方面介绍了 Go 语言,讲解了一些 Go 语言的基础语法,并编写了一个在线的命令行词典与一个简单的 SOCKS5 代理服务器,在实战演练中学习到了在 Go 语言中发送请求的方法,练习了前面学到的基础语法。
2 详细知识点的介绍
2.1 Hello World
如同我们学习其他语言一样,我们先来看最简单的 Hello World 程序。
package main
import (
"fmt"
)
func main() {
fmt.Println("hello world")
}
在这个最简单的 Go 程序中,包含三个部分。第一行的 package main 代表这个文件属于 mian 包的一部分,而 main 包为程序的入口包,说明这个文件为程序的入口文件。接下来第二部分的 import 用于导入标准库中的 fmt 包,这个包用于向屏幕输出字符串,格式化字符串。最后的的 main 函数,在 main 函数中调用了 fmt.Println("hello world") 来向屏幕输出 hello world。
要运行这个代码,可以在终端中运行 go run main.go ,或者我们可以将文件进行编译 go build main.go 再直接运行 ./main 可以达到同样的效果。
2.2 基础语法
2.2.1 变量
Go 语言与其他语言类似有整型、浮点型、布尔型等类型的变量(字符串也是内置类型),同时 Go 语言是一个强类型语言,这意味这我们应该明确变量的类型,声明一个变量应该遵循这样的写法:var 变量名字 类型 = 表达式。例如:var str string = "hello world" 。
但是 Go 语言支持通过等号右侧的代码进行类型推断,并不强制在声明时表面变量的类型,在声明变量时可以这样写:var a = 1 这一行代码代表声明了一个 int 类型的变量 a 赋值为 1。当然,如果你不想在变量声明时指定初始,编译器无从猜测,那就必须为变量指定类型。在这种情况下,Go 语言会和 java 类似对变量进行零值初始化。
如果你不想每一次都写 var,那么还有一种简化写法:名字 := 表达式。这种情况下,变量的类型根据表达式来自动推导。但这样不意味无法指定变量类型,在简化写法下要指定变量类型可以通过这样的方式 f := float32(e) ,先将右侧的值强转为需要的类型,再由其进行猜测。
[!warning] 注意 “:=”是一个变量声明语句,而“=”是一个变量赋值操作
说完基础变量的声明,那么再来说说数组。声明数组的写法为 var a [5]int 或者 a[5] = 100。但是在真实的 codeing 中我们很少直接使用数组,因为数组长度无法扩展。我们更常用的是切片。
2.2.2 流程控制
2.2.3 条件判断
Go 语言中的 if else 与 java、c++语言中的类似,不过 Go 语言去掉了判断条件的括号 (,并且必须再判断条件后紧跟大括号 {
// java style
if (true){
//...
}
// Go style
if true {
//...
}
Go 语言中的 swich 分支结构的判断条件同样不需要括号。Go 语言不需要为每一个 case 添加 break,在执行完一个 case 后会自动跳出终止。Go 语言中的 swich 更加强大,判断条件可以是任意类型,采用以下写法可以替换掉大量 if else 的嵌套,使代码逻辑更加清晰。
t := time.Now()
switch {
case t.Hour() < 12:
fmt.Println("It's before noon")
default:
fmt.Println("It's after noon")
}
2.2.4 循环
Go 语言中没有 while 和 do while 循环,只有 for 循环。
最简单的 for 循环如果不写判断条件则是一个死循环,等同于其他语言中的 while(true),也可以写类似 java 和 c 中的三段式 for 循环。
Go 语言中的 for 循环同样支持 break 和 continue 来中断循环或者继续循环,这一点与其他语言类似。
2.2.5 Slice(切片)
声明切片:s := make([]string, 3),可以简单将slice理解为一个可变长度的slice,相比数组他也有更多的操作。
slice可以像普通数组一样直接赋值。
s[0] = "a"
s[1] = "b"
s[2] = "c"
slice可以用 append 方法直接向slice最后添加元素 s = append(s, "e", "f"),要注意使用 append 方法时必须赋值给原来的slice变量,这是因为容量不够时 append 会扩容返回新的地址。
在创建时slice时也可以指定长度 c := make([]string, len(s)),可以用 copy(c, s) 进行复制。
slice还支持类似 python 中的slice操作,例如:s[2:5]//左闭右开。但与 python 不同的是 Go 中的slice不支持负数索引。
2.2.6 Map
创建一个 map:m := make(map[string]int) 在这里方括号中是 key 类型,后面紧跟的是 value 类型。这也很好理解,把他想象为一个数组,方括号内即为索引(key)。
以下为常见的对 map 的操作
m["one"] = 1
m["two"] = 2
fmt.Println(m) // map[one:1 two:2]
fmt.Println(len(m)) // 2
fmt.Println(m["one"]) // 1
fmt.Println(m["unknow"]) // 0
r, ok := m["unknow"]
fmt.Println(r, ok) // 0 false
delete(m, "one")
m2 := map[string]int{"one": 1, "two": 2}
var m3 = map[string]int{"one": 1, "two": 2}
fmt.Println(m2, m3) //这里是为了说明map的遍历时的顺序时随机的
2.2.7 Range
通过 Rang 可以快速遍历 Slice 和 map 使代码更加简洁(类似 java 中的 for-each?)。
nums := []int{2, 3, 4}
sum := 0
for i, num := range nums { //遍历数组时,返回索引与对应值
sum += num
if num == 2 {
fmt.Println("index:", i, "num:", num) // index: 0 num: 2
}
}
fmt.Println(sum) // 9
m := map[string]string{"a": "A", "b": "B"}
for k, v := range m { //遍历map时,返回k-v对
fmt.Println(k, v) // b 8; a A
}
for k := range m {
fmt.Println("key", k) // key a; key b
}
2.2.8 函数
这是 Go 语言中的一个函数:
func add(a int, b int) int {
return a + b
}
函数以 func 关键字开头,后面紧跟函数签名。在 Go 语言中函数的返回类型写在函数参数列表之后(这一点与声明变量时类似,声明变量时类型也是在变量之后)。
Go 语言中的函数可以返回多个值,我们可以使用这个特性在返回值的同时返回错误信息。
func exists(m map[string]string, k string) (v string, ok bool) {
v, ok = m[k]
return v, ok
}
2.2.9 指针
在 Go 中同样支持指针,不过相比 C/C++, Go 中的指针操作十分有限,主要的用于对传入参数进行修改。
func add2(n int) { //无效
n += 2
}
func add2ptr(n *int) { //有效
*n += 2
}
func main() {
//...
add2ptr(&n) //调用时要使用&传入地址
//...
}
2.2.10 结构体
声明一个用户结构体,其中包含用户名和密码两个成员变量
type user struct {
name string
password string
}
初始化的两种方式:
a := user{name: "wang", password: "1024"} // 指定成员变量进行赋值
b := user{"wang", "1024"} // 按顺序进行赋值
如果只指定一部分的值,对于没有初始化的值为空值。
c := user{name: "wang"}
或者我们也可以使用 var 来声明一个结构体的变量,例如 var d = user。然后我们用 d.name = 'wang' 这样的方法对成员变量进行赋值。
将结构体传入函数可以使用指针,也可以不用指针,如果使用指针,则可以对结构体的内容进行修改,而且可以避免一些对大结构体复制造成的性能损失。
func checkPassword(u user, password string) bool{...}
func checkPassword2(u *user, password string) bool{...}
也可以给结构体定义方法,类似于其他面向对象的语言中的类成员方法。
func (u user) checkPassword(password string) bool {...}
func (u *user) resetPassword(password string) {...}
func main() {
a := user{name: "wang", password: "1024"}
a.resetPassword("2048") // 调用,这里可以不同使用&
fmt.Println(a.checkPassword("2048")) // true
}
2.2.11 错误处理
在 Go 语言中我们习惯用一个单独的返回值来传递错误信息,这样可以准确的定位到错误的位置,也很方便进行流程控制。
例如在以下函数中,使用了 err 类型来处理出错的情况。
func findUser(users []user, name string) (v *user, err error) {
for _, u := range users {
if u.name == name {
return &u, nil
}
}
return nil, errors.New("not found")
}
2.2.12 JSON 处理
对于一个现有的结构体我们只需要将结构体的每一个成员首字母大写(即让其变为公开字段)即可使用 json.Marshal 方法对其进行序列化。同时我们也可以对一个序列化后的字符串使用 json.Unmarshal 进行反序列化。在输出时要使用 string 类型转换,否则将输出为十六进制数字。如果不想要输出的字段名为大写,可以给字段后加加一个 tag 或者说是别名。如下所示:
type userInfo struct {
Name string
Age int `json:"age"`
Hobby []string
}
2.2.13 时间
time.Now() 直接获取当前的时间
time.Date(2022, 3, 27, 1, 25, 36, 0, time.UTC) 创建一个带时区的时间。
t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute() 分别获取年月日时分
t1.sub(t2) 用 t1 减去 t2 获取一个时间段
t.Format("2006-01-02 15:04:05") 格式化时间,注意这里的格式化模板一定要使用这个 2006-01-02 15:04 :05
time.Parse("2006-01-02 15:04:05", "2022-03-27 01:25:36") 将字符串按模板解析为时间
t.Unix() 将时间转换为时间戳
2.2.14 获取进程信息
我们可以使用 os 包来获取进程和系统的一些信息,例如使用 os.Args 就可以获取到运行程序时的参数。os.Getenv(key string) 和 os.Setenv(key string, balue string) 可以获取和设置环境变量。exec.Command("grep", "127.0.0.1", "/etc/hosts").CombinedOutput() 可以快速启动子进程,并获取输出。
3 实践练习例子
3.1 猜谜游戏
创建一个程序随机生成一个数字,程序要告诉玩家猜的数字是大于目标数字还是小于目标数字,然后玩家需要程序的提示尽快猜出数字是多少。通过这个例子,可以练习 Go 语言中随机数的使用与 Go 的流程控制语法。
3.1.1 随机数
要实现这个程序,首先我们应该创建一个随机数字。
首先我们应该引入 math/rand 包,使用其中的 rand.Intn(n int) int 方法来创建随机数字,这个方法需要一个参数,来确定随机生成数字的最大值。但是当我们直接使用 rand.Intn(n int) int 这个方法就会发现,每一次生成的随机数是相同的,这显然不符合我们的预期。
这是因为 rand.Intn(n int) int 每次用了同一个种子来生成随机数,所以我们在调用 rand.Intn(n int) int 之前应该为他设置一个随机种子,这里我们使用当前时间戳来作为这个种子 rand.Seed(time.Now().UnixNano()),这样再次调用时就可以看到每一次生成的数字时不同的了。
func main() {
maxNum := 100
rand.Seed(time.Now().UnixNano())
secretNumber := rand.Intn(maxNum)
fmt.Println("The secret number is ", secretNumber)
// TODO
)
3.1.2 输入处理
接下来需要获取玩家的输入数据。
我们使用标准库中的 bufio 和 os 包来读取用户输入,创建一个 bufio.Reader,它从 os.Stdin 中读取输入流。使用 ReadString 方法来读取输入流,直到遇到换行符为止。将这个读取到的字符转换为数字,记得要检查是否有读取错误,是否符合我们的预期,如果有错误打印错误信息并重新读取。
func main() {
// 生成随机数
fmt.Println("Please input your guess")
reader := bufio.NewReader(os.Stdin)
for {
input, err := reader.ReadString('\n')
if err != nil {
fmt.Println("An error occured while reading input. Please try again", err)
continue
}
input = strings.Trim(input, "\r\n")
guess, err := strconv.Atoi(input)
if err != nil {
fmt.Println("Invalid input. Please enter an integer value")
continue
}
fmt.Println("You guess is", guess)
}
// TODO
}
3.1.3 最后
最后使用流程控制语句,判断玩家猜的数字是大于目标数字还是小于目标数字,并给出对应提示,如果猜对了就结束程序。如下:
func main() {
// 生成随机数
for {
// 获取输入
if guess > secretNumber {
fmt.Println("Your guess is bigger than the secret number. Please try again")
} else if guess < secretNumber {
fmt.Println("Your guess is smaller than the secret number. Please try again")
} else {
fmt.Println("Correct, you Legend!")
break
}
}
}
3.1.4 课后作业:使用 scanner 替换 reader
// ...
scanner := bufio.NewScanner(os.Stdin)
for {
scanner.Scan()
input := scanner.Text()
guess, err := strconv.Atoi(input)
if err != nil {
fmt.Println("Invalid input. Please enter an integer value")
continue
}
fmt.Println("You guess is", guess)
// ...
}
相比于使用 reader 要简洁很多,在读取时不用处理错误了,只用在字符串转整型时处理错误即可。
实现效果:
3.2 命令行在线词典
这个程序需要做到在调用程序时,将要查询的英文单词作为程序参数传入,程序将调用第三方 API 查询出单词的音标与含义输出到控制台,通过这个程序可以学习到如何使用 Go 语言来发送 Http 请求以及如何解析 JSON。
3.2.1 从浏览器获取请求命令
首先浏览器打开 彩云小译并打开开发者工具,在网页翻译一个单词,找到开发者工具 Network (或者叫网络)页面中一个叫 dict 的 POST 请求,这个请求的请求负载应该类似于:
{
"trans_type": "en2zh",
"source": "test"
}
而响应应该类似这样的:
{
"rc": 0,
"wiki": {
...
},
"dictionary": {
"prons": {
"en-us": "[tεst]",
"en": "[test]"
},
"explanations": [
"n.,vt.试验,测试,检验"
],
"synonym": [
...
],
"antonym": [],
"wqx_example": [
...
],
"entry": "test",
"type": "word",
"related": [],
"source": "wenquxing"
}
}
可以看到响应中包含了音标、释义、例句等等的数据。
接下来我们要尝试使用 Go 语言来发送一个这样的 POST 请求并且解析获取到的 JSON 数据。
3.2.2 生成 Go 语言程序
如果要手动输入来编写一个请求将十分繁杂并且容易出错,因此我们右键这个请求选择 Copy as cURL,获取到 cURL 命令。再将这个 cURL 命令复制到 PostMan 中,使用 PostMan 将其转换为 Go 语言的程序,即可快速获取这个请求的 Go 语言写法(或者使用 Convert curl commands to Go)。
3.2.3 创建请求
观察刚刚生成的程序的前几行,使用了标准库中的 http 包来发起一个 HTTP POST 请求,如下:
client := &http.Client{}
var data = strings.NewReader(`{"trans_type":"en2zh","source":"good"}`)
req, err := http.NewRequest("POST", "https://api.interpreter.caiyunai.com/v1/dict", data)
if err != nil {
log.Fatal(err)
}
程序先创建了一个 HTTP 客户端,用于向服务器发起请求。然后创建了一个字符串类型的变量 data,它包含了请求体中的数据。这里使用了 strings.NewReader 函数将数据转换为 io.Reader 类型的一个流(这样做的目的是,当 date 体积很大时,如果直接读取到内存中会引起很大的内存消耗,而使用流则可以解决这个问题)。使用 http.NewRequest 函数创建了一个请求。第一个参数表示请求使用 POST 方法,第二个参数是请求的 URL,第三个参数 data 表示请求体中的数据。最后检查是否有创建请求错误,如果有错误,就会打印错误信息并结束进程。
程序接下来的部分时一些请求头的设定,可以忽略。如下:
req.Header.Set("Connection", "keep-alive")
req.Header.Set("DNT", "1")
//...
3.2.4 发起请求
请求头设置完之后就可以发起请求了。通过 resp, err := client.Do(req) 这一行,程序将发起我们设置好的请求,之后同样要对错误进行处理,如果出错就打印错误信息并退出程序。请求完成后使用注册延迟调用的机制,用 defer resp.Body.Close() 关闭连接(理解为类似 java 中的 finally?)。
3.2.5 处理响应
使用 bodyText, err := io.ReadAll(resp.Body) 将响应的流转换为一个 byte 数组,如果此时打印这段代码,可以看到一段 JSON 的代码。
到这里最基本的使用 Go 语言发送 HTTP 请求就完成了。
3.2.6 完善请求
当前的程序只能查询一个固定的单词,并且打印的无用数据太多,显然还不符合我们的要求。因此我们要对请求的请求体与响应进行修改。
要生成一个 JSON 的请求体就要有一个对应的结构体:如下
type DictRequest struct {
TransType string `json:"trans_type"`
Source string `json:"source"`
}
创建一个结构的实例,使用读取到的参数来初始化这个结构体的实例。再用 json.Marshal 方法序列化这个结构体变成一个 byte 数组。这次使用 bytes.NewReader 将 byte 数组转换成流,完成请求的构建。
request := DictRequest{TransType: "en2zh", Source: word}
buf, err := json.Marshal(request)
if err != nil {
log.Fatal(err)
}
var data = bytes.NewReader(buf)
req, err := http.NewRequest("POST", "https://api.interpreter.caiyunai.com/v1/dict", data)
if err != nil {
log.Fatal(err)
}
3.2.7 解析响应
最佳的处理方案是和请求体一样创建一个对应的结构体,将返回的 JSON 数据反序列化到这个结构体里面。
首先在浏览器开发工具中将返回的 JSON 数据复制,粘贴到 JSON转Golang Struct - 在线工具 - OKTools 中(如果你使用 GoLand 开发那么可以直接将 JSON 粘贴到 GoLand 中,GoLand 将自动提示是否将 JSON 转换为 Go 结构体)。
type DictResponse struct {
Rc int `json:"rc"`
Wiki struct {
KnownInLaguages int `json:"known_in_laguages"`
Description struct {
// ...
} `json:"description"`
ID string `json:"id"`
Item struct {
// ...
} `json:"item"`
// ...
} `json:"wiki"`
Dictionary struct {
Prons struct {
//...
} `json:"prons"`
// ...
} `json:"dictionary"`
}
将这个巨大的结构体生成后,在程序的末尾添加:
var dictResponse DictResponse
err = json.Unmarshal(bodyText, &dictResponse)
if err != nil {
log.Fatal(err)
}
fmt.Printf("%#v\n", dictResponse)
可以看到现在的响应体已经转换到了这个结构体里面。但是这个结构体中大多数信息我们并不需要,我们只需要打印出我们需要的信息,有了结构体现在非常方便就可以做到:
fmt.Println(word, "UK:", dictResponse.Dictionary.Prons.En, "US:", dictResponse.Dictionary.Prons.EnUs)
for _, item := range dictResponse.Dictionary.Explanations {
fmt.Println(item)
}
注意这里的释义是一个数组,我们使用 range 来遍历打印。
3.2.8 最后
完善代码,将错误处理添加上,代码大致如下:
//...
type DictRequest struct {
//...
}
type DictResponse struct {
// ...
}
func query(word string) {
client := &http.Client{}
request := DictRequest{TransType: "en2zh", Source: word}
buf, err := json.Marshal(request)
if err != nil {
log.Fatal(err)
}
var data = bytes.NewReader(buf)
req, err := http.NewRequest("POST", "https://api.interpreter.caiyunai.com/v1/dict", data)
if err != nil {
log.Fatal(err)
}
req.Header.Set("Connection", "keep-alive")
// req.Header.Set...
resp, err := client.Do(req)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
bodyText, err := ioutil.ReadAll(resp.Body)
if err != nil {
log.Fatal(err)
}
if resp.StatusCode != 200 {
log.Fatal("bad StatusCode:", resp.StatusCode, "body", string(bodyText))
}
var dictResponse DictResponse
err = json.Unmarshal(bodyText, &dictResponse)
if err != nil {
log.Fatal(err)
}
fmt.Println(word, "UK:", dictResponse.Dictionary.Prons.En, "US:", dictResponse.Dictionary.Prons.EnUs)
for _, item := range dictResponse.Dictionary.Explanations {
fmt.Println(item)
}
}
func main() {
if len(os.Args) != 2 {
fmt.Fprintf(os.Stderr, `usage: simpleDict WORD example: simpleDict hello
`)
os.Exit(1)
}
word := os.Args[1]
query(word)
}
3.2.9 课后练习
实现了接入火山翻译的
package main
import (...)
type DictResponse struct {
TranslationList []struct {
Translation string `json:"Translation"`
DetectedSourceLanguage string `json:"DetectedSourceLanguage"`
} `json:"TranslationList"`
ResponseMetadata struct {
RequestID string `json:"RequestId"`
// ...
Error interface{} `json:"Error"`
} `json:"ResponseMetadata"`
}
const (
kAccessKey = "..."
kSecretKey = "..."
kServiceVersion = "2020-06-01"
)
var (
ServiceInfo = &base.ServiceInfo{
// ...
}
ApiInfoList = map[string]*base.ApiInfo{
// ...
}
)
func newClient() *base.Client {
client := base.NewClient(ServiceInfo, ApiInfoList)
client.SetAccessKey(kAccessKey)
client.SetSecretKey(kSecretKey)
return client
}
func main() {
if len(os.Args) != 2 {
_, err := fmt.Fprintf(os.Stderr, `usage: simpleDict WORD example: simpleDict hello`)
if err != nil {
return
}
os.Exit(1)
}
word := os.Args[1]
client := newClient()
resp, code, err := client.Json("TranslateText", nil, `{"TargetLanguage":"zh","TextList":["`+word+`"]}`)
if err != nil {
log.Fatal(err)
}
if code != http.StatusOK {
log.Fatal("http status code:", code)
}
var dictResponse DictResponse
err = json.Unmarshal(resp, &dictResponse)
if err != nil {
log.Fatal(err)
}
fmt.Println(word, ":", dictResponse.TranslationList[0].Translation)
}
火山翻译不能直接获取到 POST 请求的方式,需要开通 API,使用给出的 SDK 完成接入。而这个 SDK 已经帮我们做了大部分的工作,包括请求头响应体的转换,我们只需要将响应体解析到结构体中,再输出结构体中需要的信息即可。
3.3 SOCKS5 代理服务器
实现一个 SOCKS5 代理服务器的简单版本。
SOCKS5 代理的原理:
3.3.1 TCP echo server
首先我们先创建一个 TCP 的服务器,将客户端发送来的请求 echo 回去,以验证我们 TCP 服务器是政策运行。
func main() {
server, err := net.Listen("tcp", "127.0.0.1:1080")
if err != nil {
panic(err)
}
for {
print("waiting for client")
client, err := server.Accept()
if err != nil {
log.Printf("Accept failed %v", err)
continue
}
go process(client)
}
}
在这段程序中 net.Listen("tcp", "127.0.0.1:1080") 创建了一个 TCP 服务器,监听在本地 IP 地址 127.0.0.1,端口号 1080。当有新的客户端连接到这个端口时,服务器会调用 process 函数来处理这个连接。这里 go process(client) 是一个 Go 关键字,它会创建一个新的 goroutine (子线程) 来执行 process(client) 函数。
Goroutine 是 Go 语言中的轻量级线程,它可以在单个进程中并发执行多个任务。在这段代码中,当服务器接收到新的客户端连接时,会立即调用 go process(client) 来处理这个连接,而不是等待当前的客户端处理完毕断开后再处理下一个。这样可以使服务器在处理多个客户端的同时,提高服务器的吞吐量。
func process(conn net.Conn) {
defer conn.Close()
println("client connected")
reader := bufio.NewReader(conn)
for {
b, err := reader.ReadByte()
if err != nil {
break
}
_, err = conn.Write([]byte{b})
if err != nil {
break
}
}
println("client disconnected")
}
process 函数在循环中读取客户端发来的数据,并将这些数据原样返回给客户端。当客户端断开连接时,服务器会打印 "client disconnected"。
3.3.2 认证
我们已经验证了 TCP 服务器的正确性,下一步我们将加上认证功能。这里用到一个 auth 函数,函数签名如下:
auth(reader *bufio.Reader, conn net.Conn) (err error)
认证阶段客户端会给服务器发送报文,这个报文包括三个字段分别是:VER: 协议版本(socks5 为 0x05)、NMETHODS: 支持认证的方法数量、METHODS: 对应的认证的方法,NMETHODS 决定了他的长度 (00 表示不需要身份验证,02 表示用户名/密码认证)。因此在这个函数中我们首先处理这个报文。
ver, err := reader.ReadByte()
if err != nil {
return fmt.Errorf("read ver failed:%w", err)
}
if ver != socks5Ver {
return fmt.Errorf("not supported ver:%v", ver)
}
methodSize, err := reader.ReadByte()
if err != nil {
return fmt.Errorf("read methodSize failed:%w", err)
}
method := make([]byte, methodSize)
_, err = io.ReadFull(reader, method)
if err != nil {
return fmt.Errorf("read method failed:%w", err)
}
log.Println("ver", ver, "method", method)
这这个过程中,任何一步出错都会直接返回错误信息,process(conn net.Conn) 函数接收到错误信息后就会直接断开连接。
在终端执行 curl --socks5 127.0.0.1:1080 -v http://www.qq.com 可以看到程序打印出了日志信息 2023/01/16 15:51:30 ver 5 method [0 1] 说明当前实现是正确的。
3.3.3 请求
在认证通过后,客户端会发来下一个报文,其中包括:
- VER 版本号,socks5 的值为 0x05
- CMD 0x01 表示 CONNECT 请求
- RSV 保留字段,值为 0x00
- ATYP 目标地址类型,DST. ADDR 的数据对应这个字段的类型。 0x01 表示 IPv4 地址,DST. ADDR 为 4 个字节 0x03 表示域名,DST. ADDR 是一个可变长度的域名
- DST. ADDR 一个可变长度的值,对应的是地址或者域名
- DST. PORT 目标端口,固定 2 个字节
现在我们再来把客户端发来的这几个字段全部解析出来,我们实现一个类型 auth 函数的 connect 函数,他们函数签名是一致的。在 process 函数中调用 connect 函数。因为前四个字段是定长的包括 VER、CMD、RSV、ATYP,我们直接创建一个长度为 4 的 byte 缓冲区,一次将前四个字段读取进来,并验证他们的合法性。
buf := make([]byte, 4)
_, err = io.ReadFull(reader, buf)
if err != nil {
return fmt.Errorf("read header failed:%w", err)
}
ver, cmd, atyp := buf[0], buf[1], buf[3]
if ver != socks5Ver {
return fmt.Errorf("not supported ver:%v", ver)
}
if cmd != cmdBind {
return fmt.Errorf("not supported cmd:%v", ver)
}
在确保前三个字段无误后,使用 swich 语句进入 ATYP 对应的流程来处理目标地址:
addr := ""
switch atyp {
case atypIPV4:
_, err = io.ReadFull(reader, buf)
if err != nil {
return fmt.Errorf("read atyp failed:%w", err)
}
addr = fmt.Sprintf("%d.%d.%d.%d", buf[0], buf[1], buf[2], buf[3])
case atypeHOST:
hostSize, err := reader.ReadByte()
if err != nil {
return fmt.Errorf("read hostSize failed:%w", err)
}
host := make([]byte, hostSize)
_, err = io.ReadFull(reader, host)
if err != nil {
return fmt.Errorf("read host failed:%w", err)
}
addr = string(host)
case atypeIPV6:
return errors.New("IPv6: no supported yet")
default:
return errors.New("invalid atyp")
}
到这里前五个字段就已经处理好了。然后我们用类似的方法,获取到端口号:
_, err = io.ReadFull(reader, buf[:2])
if err != nil {
return fmt.Errorf("read port failed:%w", err)
}
port := binary.BigEndian.Uint16(buf[:2])
最后,按照协议我们应该给客户端发送一个返回包,包含以下字段:
- VER:socks 版本,这里为 0x05
- REP:Relay field, 内容取值如下 0x00 succeeded
- RSV:保留字段
- ATYPE:地址类型
- BND. ADDR:服务绑定的地址
- BND. PORT:服务绑定的端口 DST. PORT
实现如下:
_, err = conn.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
if err != nil {
return fmt.Errorf("write failed: %w", err)
}
return nil
3.3.4 relay 阶段
下一步就是完成真正的数据转发了,我们继续完善 connect 函数,首先建立与目标网站的连接,命名为 dest,再使用两个 Goroutine 调用 io 包中的 Copy 函数,即可实现双向转发。为了避免主进程退出,我们要用到 context 。创建一个 context 在 Goroutine 执行出错时即会调用 cancel()
context 执行完成,此时主进程退出。
dest, err := net.Dial("tcp", fmt.Sprintf("%v:%v", addr, port))
if err != nil {
return fmt.Errorf("dial dst failed:%w", err)
}
defer dest.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
_, _ = io.Copy(dest, reader)
cancel()
}()
go func() {
_, _ = io.Copy(conn, dest)
cancel()
}()
3.3.5 最后
至此代理服务器就完成了。完整代码如下:
package main
import (...)
const socks5Ver = 0x05
const cmdBind = 0x01
const atypIPV4 = 0x01
//...
func main() {
server, err := net.Listen("tcp", "127.0.0.1:1080")
if err != nil {
panic(err)
}
for {
client, err := server.Accept()
if err != nil {
log.Printf("Accept failed %v", err)
continue
}
go process(client)
}
}
func process(conn net.Conn) {
defer conn.Close()
reader := bufio.NewReader(conn)
err := auth(reader, conn)
if err != nil {
log.Printf("client %v auth failed:%v", conn.RemoteAddr(), err)
return
}
err = connect(reader, conn)
if err != nil {
log.Printf("client %v auth failed:%v", conn.RemoteAddr(), err)
return
}
}
func auth(reader *bufio.Reader, conn net.Conn) (err error) {
//...
}
func connect(reader *bufio.Reader, conn net.Conn) (err error) {
buf := make([]byte, 4)
_, err = io.ReadFull(reader, buf)
if err != nil {
return fmt.Errorf("read header failed:%w", err)
}
ver, cmd, atyp := buf[0], buf[1], buf[3]
if ver != socks5Ver {
return fmt.Errorf("not supported ver:%v", ver)
}
if cmd != cmdBind {
return fmt.Errorf("not supported cmd:%v", ver)
}
addr := ""
switch atyp {
case atypIPV4:
_, err = io.ReadFull(reader, buf)
if err != nil {
return fmt.Errorf("read atyp failed:%w", err)
}
addr = fmt.Sprintf("%d.%d.%d.%d", buf[0], buf[1], buf[2], buf[3])
case atypeHOST:
hostSize, err := reader.ReadByte()
if err != nil {
return fmt.Errorf("read hostSize failed:%w", err)
}
host := make([]byte, hostSize)
_, err = io.ReadFull(reader, host)
if err != nil {
return fmt.Errorf("read host failed:%w", err)
}
addr = string(host)
case atypeIPV6:
return errors.New("IPv6: no supported yet")
default:
return errors.New("invalid atyp")
}
_, err = io.ReadFull(reader, buf[:2])
if err != nil {
return fmt.Errorf("read port failed:%w", err)
}
port := binary.BigEndian.Uint16(buf[:2])
dest, err := net.Dial("tcp", fmt.Sprintf("%v:%v", addr, port))
if err != nil {
return fmt.Errorf("dial dst failed:%w", err)
}
defer dest.Close()
log.Println("dial", addr, port)
_, err = conn.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
if err != nil {
return fmt.Errorf("write failed: %w", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
_, _ = io.Copy(dest, reader)
cancel()
}()
go func() {
_, _ = io.Copy(conn, dest)
cancel()
}()
<-ctx.Done()
return nil
}
总结
今天的课程带我快速上手了 Go 语言,我学会了基本语法与基本的HTTP请求发送方式,关于最后一个 SOCKS5 代理服务器我还需要反复学习,以更加深入理解。