Go语言基础

Entropy Lv4

本文来源于第五届字节跳动青训营活动,已收录到golang基础学习 | 青训营笔记 - 掘金 (juejin.cn) ,主要记录了对golang的初步学习

1.1 什么是Go语言

1.高性能、高并发

拥有和C++、Java媲美的性能,拥有对高并发的支持,不需要额外的第三方库,只需要使用标准库或者基于标准库的第三方库就能进行高并发开发

2.语法简单、学习曲线平缓

语法风格类似C语言,且在C语言的基础上进行了大幅度简化,例如去掉了不必要的表达式括号,循环只有for循环一种方式就能实现各种数值、键值遍历

一个基于Go的简单的Http服务器demo

1
2
3
4
5
6
7
8
9
10
package main

import (
"net/http"
)

func main() {
http.Handle("/", http.FileServer(http.Dir(".")))
http.ListenAndServer(":8080", nil)
}

3.丰富的标准库

拥有及其丰富、功能完善、质量可靠的标准库。在很多情况下,不需要借助第三方库就可以完成大部分基础功能的开发,大大降低了学习和使用成本。最关键的是,标准库具有很高的稳定性和兼容性保障,还能持续享受语言迭代所带来的性能优化。这是第三方库所不具备的

4.完善的工具链

拥有丰富的工具链,编译、代码格式化、错误检查、帮助文档、包管理以及代码补全提示。Go还内置了完整的单元测试框架,支持单元测试、性能测试、代码覆盖率、数据键增检测、性能优化,保障代码能够正确稳定运行

5.静态链接

在Go语言中所有的编译结构默认为静态链接,只需要编译后的唯一一个可执行文件不需要附加任何其它东西,即可部署运行。在线上的容器环境中运行,镜像体积可以控制得非常小,部署非常方便快捷。不同于C++,需要一堆动态链接库(linux下表现为.so文件,win下是.dll文件以及由.dll生成的.lib文件)才可以正确运行,文本不正确的话就会崩溃。Java则需要附加一个庞大的JRE才能运行

6.快速编译

Go语言拥有静态语言中几乎最快的编译速度,增量编译完成。这个速度对C++开发来说不可想象

7.跨平台

Go语言能在常见的linux、windows、macos,也能够开发android、ios软件,还能在一些硬件设备上运行,例如路由器、树莓派。Go还拥有很方便的交叉编译特性,能够轻松在笔记本上编译出二进制文件拷贝到路由器中运行,而无需配置交叉编译环境

8.垃圾回收

Go语言自带垃圾回收机制,和Java类似,在开发的时候无需考虑内存的分配和释放,可以专注于业务逻辑

1.2 哪些公司在使用Go语言

ByteDance字节跳动、Google谷歌、Tencent腾讯、facebook脸书、bilibili哔哩哔哩等

在云计算、微服务、大数据、区块链、物联网等领域广泛发展,尤其在云计算、微服务领域产出了大量的云原生组件

1.3 为什么选择Go语言

  1. 随着业务体量的不断增长,Python对于Web业务存在性能瓶颈
  2. C++的特性使其不太适合在线Web业务
  3. Go的学习难度低于Java
  4. Go的性能比较好
  5. Go的部署简单,学习成本低(没有Python的依赖库版本问题)
  6. 基于Go研发的内部RPC框架和HTTP框架,推动了业务重构

2.1 开发环境

安装Golang

Golang官网

Golang镜像

Golang第三方包代理加速下载

配置集成开发环境

以下三种方案选其一即可

  1. Visual Studio Code

  2. Goland

    关于Goland控制台输出#gosetup的多余信息,影响观察。快捷键Ctrl+Alt+shift+/,打开Registry,取消勾选go.run.processes.with.pty。之后#gosetup的信息会被折叠,方便直接观察程序输出结果

  3. 云开发环境Gitpod

2.2 基础语法

1.Hello World

main.go文件

1
2
3
4
5
6
7
8
9
package main

import (
"fmt"
)

func main() {
fmt.Println("hello world")
}
  • package main代表这个文件属于main包,main包是程序的入口包

  • fmt包是标准库里的包,主要用于输入输出字符串,格式化字符串

  • func main就是go语言main函数的声明,需要注意的是 花括号{ 必须和func main写在同一行,否则会报错

    1
    2
    # command-line-arguments
    .\test.go:6:1: syntax error: unexpected semicolon or newline before {
  • 在main函数里用fmt.Println调用了fmt包中的Println函数,需要注意的是import的包必须要在程序中使用到,不能只是import这个包而不去使用,会报错

    1
    2
    # command-line-arguments
    .\test.go:3:8: imported and not used: "fmt"

编译运行命令(在文件的同级目录下run或者build后执行)

1
2
3
4
go run main.go

go build main.go
./main
  • go run 直接运行go文件
  • go build会生成一个exe文件,运行exe文件得到结果

2.变量

go语言是一门强类型语言,每个变量都有各自的变量类型。

常见的变量类型:字符串、整型、浮点型、布尔型等。

go语言的字符串是内置类型,可以直接通过+号拼接,也能够直接使用==去比较两个字符串(不同于Java需要使用equals方法去比较)。

go语言大部分运算符的使用和优先级和C/C++类似。

变量声明

在go语言中变量的声明方式有两种

一种是通过var name string = ""这种方式来声明变量,声明变量的时候一般会自动推导变量的类型,有需要也可以显式写明变量类型。

1
2
3
var name string = ""
var str = "hello" //自动推导变量类型
var t1,t2 int //可以声明多个相同类型的变量

另一种变量声明是短声明,使用 变量 := 值 的格式。

1
name := ""

需要注意的是使用var声明过的变量名,不能用短声明重复声明。

go语言的常量,就是用const关键字替代var关键字声明。需要注意的是,go语言中的常量没有一个确定的类型,它会根据上下文来自动确定类型。

3.if else

go语言中的if else不同于其他语言

go语言中if后面没有小括号()

go语言中if后面必须要有大括号{},没有C/C++那种缺省大括号写在同一行的写法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// if-else
if 7%22==0 {
fmt.Println("7 is even")
} else {
fmt.Println("7 is odd")
}

// if后可以加上短声明和判断
if num := 9; num < 0 {
fmt.Println(num, "is negative")
} else if num < 10 {
fmt.Println(num, has 1 digit)
} else {
fmt.Println(num, "has multiple digits")
}

4.循环

go语言中没有while循环、do while循环,只有唯一的一种for循环。

最简单的for循环就是只有for没有循环条件,代表死循环。

循环中途可以用break跳出,也可以使用经典的循环(初值,阈值,步长),这三段中的任何一段都可以省略。

在循环里面还可以用continue直接进入下一个循环(在满足循环条件的情况下)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 无条件循环(死循环),使用break跳出循环
for {
fmt.Println("loop")
break
}
// 经典循环写法
for j := 7; j < 9; j++ {
fmt.Println(j)
}
// continue跳过本轮循环,直接进入下一轮循环
for n := 0; n < 5; n++ {
if n%2 == 0 {
continue
}
fmt.Println(n)
}
// 赋值、判断、增值分开写
i := 1
for i <= 3 {
fmt.Println(i)
i = i + 1
}

5.switch

go语言中的switch分支结构,也类似于C/C++。同样地,go语言的switch后面不需要小括号。

但是和C/C的switch不同,在C里面,switch case后面如果不显式加break会继续执行后面的case;go语言则不需要break,如果要执行后面的case还需要显式加fallthrough关键字。

相比C/C++,go语言的switch功能更加强大,可以使用任意的变量类型,甚至可以取代任意的if else语句。不在switch后面加任何的变量,在case里面写条件分支,相比多个if else语句,代码逻辑更加清晰

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// switch
a := 2
switch a {
case 1:
fmt.Println("one")
case 2:
fmt.Println("two")
case 3:
fmt.Println("three")
case 4,5:
fmt.Println("four or five")
default:
fmt.Println("default")
}

6.数组

数组是一个具有编号且长度固定的元素序列。

对于一个数组,可以很方便地取特定索引的值或者在特定索引存储值,但实际开发中,很少直接使用数组,因为其长度是固定的,使用更多的是切片。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 数组声明,未初始化默认值为0(对于int类型数组)
var a [5]int
// 对索引为4的元素赋值
a[4] = 100
fmt.Println("get:", a[2]) //a[2]未初始化,默认是0
fmt.Println("len:", len(a)) //获取数组a的长度5

// 短声明,直接赋值
b := [5]int{1,2,3,4,5}
fmt.Println(b)

// 二维数组,可直接赋值,也可以用嵌套循环赋值
var twoD [2][3]int

7.切片

切片slice不同于数组array,可以任意更改长度,拥有更多数组不具备的操作。可以使用make来创建切片,使用append来追加元素(注意append的用法,需要把结果返回给原数组)。

slice的原理是存储了一个长度和一个容量,以及一根指向一个数组的指针。

在执行append操作时,如果容量不够,就会扩容并返回新的slice。

slice拥有类似python的切片操作,但不支持负数索引。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 短声明一个string类型的切片,初始化长度为3,容量默认和长度相等
s := make([]string,3)
s[0] = "a"
s[1] = "b"
s[2] = "c"
// append追加元素,需要把结果返回给原数组
// 如果容量不够的话,会进行扩容
s = append(s, "d")
s = append(s, "e", "f")
// 复制切片
c := make([]string,len(s))
copy(c,s) //[a b c d e f]
// 切片索引操作
fmt.Println(s[2:5]) // [c d e]
fmt.Println(s[:5]) // [a b c d e]
fmt.Println(s[2:]) // [c d e f]
// 其他初始化方式
str := []string{"s", "t", "r"}

8.map

map在其他语言里又可能被称为哈希或者字典,是实际开发中使用最频繁的数据结构。

map也可以使用make来创建,创建时需要提供两个类型,key的类型和value的类型。

map可以存储键值对形式的数据,可以通过delete删除键值对。

go语言的map是完全无序的,遍历的时候是随机顺序。

1
2
3
4
5
6
7
8
9
// 短声明一个key为string类型,value为int类型的map
m := make(map[string]int)
m["a"] = 1
m["b"] = 2
// 删除键值对
delete(m,"a")
// 其他初始化方式
m2 := map[string]int{"a": 1, "b": 2}
var m3 = map[string]int{"a": 1, "b": 2}

9.range

range能够用于快速遍历slice和map,并且代码简洁。

range遍历数组时会返回两个字,第一个是索引,第二个是对应的值。如果不需要索引可以用下划线来忽略。

1
2
3
4
5
6
7
8
9
10
11
nums := []int{2, 3, 4}
for i, num := range nums {
if num == 2 {
fmt.Println("index:", i, "num:", num)
}
}

m := map[string]string{"a": "A", "b": "B"}
for k, v := range m {
fmt.Println(k, v)
}

10.函数

go语言其他语言不同的是,go语言中函数的变量类型是后置的,且go函数原生支持多个返回值。实际开发中,几乎所有的函数都返回两个值,第一个是真正的返回结果,第二个是错误信息。

1
2
3
4
5
// 判断map的值是否存在,返回值和错误信息
func exists(m map[string]string, k string) (v string, ok bool) {
v, ok = m[k]
return v, ok
}

11.指针

go语言也支持指针,但指针操作相对于C/C++的指针比较局限。指针主要的用途就是对传入参数进行修改。

使用指针传参和不使用指针传参,主要区别就是有无拷贝的所带来开销以及能否直接修改数据。对于大型结构体来说,开销更大。

1
2
3
4
5
6
7
8
9
func add (n *int) {
*n += 2
}

func main() {
n := 5
add(&n)
fmt.Println(n)
}

12.结构体

结构体是带类型的字段的集合。可以用结构体的名称作为结构体类型去初始化一个结构体变量,构造的时候需要传入每个字段的初始值。也可以用这种键值对的方式只对一部分字段进行初始化。

结构体也支持指针,并且使用指针修改结构体能避免结构体的拷贝开销。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 结构体
type user struct {
name string
passwd string
}

func main() {
// 初始化结构体的几种方式
a := user{name: "a", passwd: "123"}
b := user{"a", "123"}
c := user{name: "a"}
c.passwd = "123"
var d user
d.name = "a"
d.passwd = "123"
fmt.Println(check(&a, "456"))
}

func check(u *user, passwd string) bool {
return u.passwd == passwd
}

13.结构体方法

结构体方法类似于其他语言中的类成员函数。结构体方法可以选择带指针或不带指针,带指针的话,就是能够去修改结构体,不带指针的话,就是只能获取结构体数据不能修改数据。

1
2
3
4
5
6
7
8
9
10
// 对照上面12的user结构体和check函数
// 结构体方法不同于函数,在一开始就指明了结构体类型
// 不需要改动数据时,不带指针操作即可
func (u user) checkPasswd(passwd string) bool {
return u.passwd == passwd
}
// 需要改动数据时,带指针操作
func (u *user) reset(passwd string) {
u.passwd = passwd
}

14.错误处理

错误处理在go语言中的语言习惯做法就是使用一个单独的返回值来传递错误信息。

不同于Java的异常处理,go语言的处理方式能够很清晰地知道哪个函数返回了错误,并且能用简单的if else来处理错误。

在函数的返回值类型里面加上error,就代表这个函数可能返回错误。在函数实现的时候,如果出错的话,就可以返回一个nil和error,如果没有出错,就返回原本的结果和nil。

1
2
3
4
5
6
7
8
9
// 以12的user结构体为例
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")
}

15.字符串操作

在标准库strings包里面有很多常用的字符串工具函数。

  • contains:判断一个字符串里面是否包含另一个字符串
  • count:统计字符串中某个字符或字符子串出现的次数
  • index:查找某个字符串第一次出现的位置
  • join:连接多个字符串,将两个字符串用字符连接起来
  • repeat:重复多个字符串
  • replace:替换字符串

16.字符串格式化

在标准库的fmt包里面有很多字符串格式相关的方法。

go语言的printf类似C语言的printf,不同的是,在go语言中,可以用%v占位符来打印任意类型的变量,而不需要区分数字、字符串,可以用%+v打印详细结果,也可以用%#v打印得更详细。

17.JSON处理

go语言的JSON操作非常简单,对于一个结构体,只要保证每个字段的首字母大写(公开字段,相当于Java中的public),那么这个结构体就能用JSON.marshal序列化成JSON字符串。

序列化之后的字符串也可以用JSON.unmarshal反序列化到一个空变量中。

默认序列化的字符串风格是大写字母开头,不是下划线,可以用json tag等语法来修改输出结果的字段名。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
type userInfo struct {
Name string
Age int `json:"age"`
Hobby []string
}

func main() {
a := userInfo{Name: "a", Age: 11, Hobby: []string{"Golang", "Typescript"}}
buf, err := json.Marshal(a) // 序列化
if err != nil {
panic(err)
}
fmt.Println(string(buf))

buf, err = json.MarshalIndent(a, "", "\t")
if err != nil {
panic(err)
}
fmt.Println(string(buf))

var b userInfo
err = json.Unmarshal(buf, &b) // 反序列化到空变量中
if err != nil {
panic(err)
}
fmt.Printf("%#v\n", b)
}

18.时间处理

go语言时间处理最常用的就是用time.now()来获取当前时间,也可以用time.date去构造一个带时区的时间。

使用sub对两个时间进行减法得到时间差,可以查看它们具体相差多少小时、多少分钟、多少秒。

在系统交互时,经常会使用到时间戳,可以使用UNIX方法生成时间戳。

需要注意的是使用format或者parse来处理时间时,都需要使用2006-01-02 15:04:05这个固定参数才能得到正确的时间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func main() {
now := time.Now()
t := time.Date(2023, 1, 20, 12, 30, 30, 0, time.UTC)
t2 := time.Date(2023, 1, 20, 13, 45, 30, 0, time.UTC)
fmt.Println(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute())
fmt.Println(t.Format("2006-01-02 15:04:05"))
diff := t2.Sub(t)
fmt.Println(diff)
fmt.Println(diff.Minutes(), diff.Seconds())
t3, err := time.Parse("2006-01-02 15:04:05", "2023-01-20 12:30:30")
if err != nil {
panic(err)
}
fmt.Println(now.Unix())
}

19.数字解析

在go语言中可以通过strconv这个包来进行字符串和数字类型之间的转换。

可以使用parseInt或parseFloat来解析一个数字字符串,使用atoi将一个十进制字符串转换为数字,使用itoa将数字转换为字符串。如果转换的数据不合法就会返回error。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func main() {
f, _ := strconv.ParseFloat("1.234", 64)
fmt.Println(f)

n, _ := strconv.ParseInt("111", 10, 64)
fmt.Println(n)

n, _ = strconv.ParseInt("0x1000", 0, 64)
fmt.Println(n)

n2, _ := strconv.Atoi("123")
fmt.Println(n2)

n2, err := strconv.Atoi("AAA")
fmt.Println(n2, err)
}

20.进程信息

在go语言中能够通过os.args来获取程序执行时指定的命令行参数。可以用os.getenv获取环境变量。exec.command用于执行系统的命令。

以下部分代码在windows系统下可能会报错,需要在linux系统运行才能获取正确信息。

1
2
3
4
5
6
7
8
9
10
11
func main() {
fmt.Println(os.Args)
fmt.Println(os.Getenv("PATH"))
fmt.Println(os.Setenv("AA", "BB"))

buf, err := exec.Command("grep", "127.0.0.1", "/etc/hosts").CombineOutput()
if err != nil {
panic(err)
}
fmt.Println(string(buf))
}

使用go run加参数运行,例如go run main.go a b c d

3.1 猜谜游戏

1.生成随机数

需要math/rand包用于生成随机数

1
2
maxNum := 100 //最大范围
random := rand.Intn(maxNum) //生成0-100之间的随机整数

注意到以上代码多次生成的数都是同一个数。查看这个包的官方文档可得知使用之前需要设置随机数种子用于生成随机数序列,否则每次生成的随机数序列都是相同的。习惯上用时间戳来初始化随机数种子。

在生成随机数之前用时间戳初始化随机数种子即可

1
rand.Seed(time.Now().UnixNano())

2.读取用户输入

每个程序执行时都会打开几个文件,stdin、stdou、stderr等。stdin可通过os.Stdin获取,但直接操作文件很不方便。

go语言中的bufio包提供了读取用户输入的方法,newReader方法能够将一个文件转换成一个reader变量,reader变量上拥有很多流的操作。使用ReadString方法读取一行,返回结果中包含结尾的换行符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
reader := bufio.NewReader(os.Stdin)
input, err := reader.ReadString('\n') //读取
if err != nil {
fmt.Println("An error occured while reading input. Please try again", err)
return
}
// 去掉换行符
input = strings.Trim(input, "\n") //这里根据不同的操作系统可能需要换成\r\n或其他参数
// 转换为数字
guess, err := strconv.Atoi(input)
if err != nil {
fmt.Println("Invalid input. Please enter an integer value")
return
}

3.实现判断逻辑

正常情况下,只有三种结果,过大、过小以及相等。使用简单的if else即可。

4.实现游戏循环

由于无法确定固定的循环次数,使用for循环配合continue和break,在读取和转换字符串失败用continue继续循环,在猜中数字时用break退出循环即可。

3.2 在线词典

通过调用第三方API查询单词的翻译并打印出来。

学会如何使用go语言来发送HTTP请求,解析json数据以及学习如何使用代码生成来提高开发效率。

1.抓包

以彩云APIhttps://fanyi.caiyunapp.com/为例,在在线翻译页面打开浏览器的开发者工具。

使用一次翻译,同时捕获查询单词的post请求,在预览里面找到具体的查询结果。

2.代码生成

在go语言里构造一个请求用于请求API,由于请求比较复杂,用代码构造麻烦。可以直接在浏览器中复制为cURL(bash)在linux终端或复制为powershell在windows的powershell下测试,成功的话就会返回一大堆json字符串。

在一个在线代码生成网站Convert curl to Go 里粘贴cURL,就可以看到生成的go语言代码。直接将代码粘贴到编辑器里即可。

部分代码解读

创建HTTP client,可以指定很多参数进行创建,如请求超时时间以及是否使用cookie等。

1
client := &http.Client{}

创建请求,使用http包中的NewRequest方法创建一个post请求,第一个参数指定请求类型,第二个参数指定URL,第三个参数指定请求体。其中请求体可能很大,为了支持流式发送,使用strings.NewReader将字符串转换为一个只读流存放在data变量中。

1
req, err := http.NewRequest("POST", "https://api.interpreter.caiyunai.com/v1/dict", data)

设置请求头,往往需要设置很多个参数

1
req.Header.Set("key", "value") //填写请求体的各种参数

发起请求,使用HTTP client发起请求,获取结果。如果请求失败便会打印错误信息并退出进程。

1
resp, err := client.Do(req)

读取响应,body同样是一个流,为了避免资源泄露,需要使用defer来手动关闭流,defer会在函数运行结束后执行。使用ioutil.ReadAll读取流,获取整个body的信息。

1
2
defer resp.Body.Close()
bodyText, err := ioutil.ReadAll(resp.Body)

目前已经能够成功发送请求,但是上面生成的代码是固定输入的。需要使用JSON序列化来实现手动输入请求参数。

3.生成request body

在go语言中,要生成一段JSON,常用的方式就是先构造一个对应json结构的结构体。注意结构体字段首字母一定要大写,否则无法访问该字段。

结构体中的字段对应了请求负载中的字段

1
2
3
4
5
type DictRequest struct {
TransType string `json:"trans_type"`
Source string `json:"source"`
UserID string `json:"user_id"`
}

json反序列化为字节数组,使用byte.NewReader来构造request body

1
2
3
4
5
6
7
8
func main() {
request := DictRequest{TransType: "en2zh", Source: "good"}
buf, err := json.Marshal(request)
if err != nil {
log.Fatal(err)
}
var data = bytes.NewReader(buf)
}

以此实现通过一个变量来发送HTTP请求。

4.解析response body

在js/python这些脚本语言中,body是一个字典或者map的结构,可以直接取值。但go语言是一个强类型语言,不适合这种直接取值的做法。常用的方式就是通过结构体,将json反序列化到结构体中。但是API返回的结构也比较复杂,可以借助代码生成工具JSON转Golang Struct - 在线工具 - OKTools 用于快速生成对应的代码,将响应的json数据通过代码生成工具生成对应的结构体。如果不需要对返回结果进行精细处理,转换为嵌套结构体即可。

得到response结构体后,使用json.Unmarshal把body反序列化到结构体中,再打印出来。

1
2
3
4
5
6
var dictResponse DictResponse //DictResponse是生成的结构体的名称
err = json.Unmarshal(bodyText, &dictResponse) //bodyText存储了body原始信息,反序列化到空变量中
if err != nil {
log.Fatal(err)
}
fmt.Printf("%#v\n", dictResponse)

之后就是打印特定字段的信息。

5.打印结果

在以上的API响应例子中,需要用到的信息是在Dictionary.explanations里面。

可以使用for range迭代并打印信息

1
2
3
for _, item := range dictResponse.Dictionary.Explanations {
fmt.Println(item)
}

6.完善代码

将代码主体改造成一个query函数,把需要查询的单词作为参数传递。

1
2
3
4
5
6
7
8
9
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)
}

这里通过命令行输入参数运行,使用go run main.go接需要查询的单词即可。

3.3 SOCKS5代理

编写一个socks5代理服务器。socks5协议都是明文传输,这个协议历史久远,诞生于互联网早期。它的用途就是在防火墙保证数据安全性时提供socks5协议的端口给授权的用户以便访问内部资源。

原理

正常浏览器访问一个网站,如果不经过代理服务器,就是先和目标网站的服务器建立TCP连接,完成三次握手后发起HTTP请求,然后服务器返回HTTP响应。

如果设置了代理服务器,那么流程会变得复杂一些。首先是浏览器和sock5代理建立TCP连接,代理再和真正的服务器建立TCP连接。可以分成四个阶段,握手阶段、认证阶段、请求阶段、relay阶段。

第一个握手阶段,浏览器会向socks5代理发送请求,数据包的内容包括一个协议的版本号、支持认证的种类,socks5服务器会选择一个认证方式,返回给浏览器。如果返回的是00则代表不需要认证,返回其他结果则开始认证流程。

第二个认证阶段参考https://wiyi.org/socks5-protocol-in-deep.html

第三个请求阶段,认证通过之后浏览器会向socks5发起请求。主要信息包括版本号,请求的类型(一般是connection请求,表示代理服务器要和某个域名或者某个IP地址的某个端口建立TCP连接)。代理服务器收到响应后会真正和后端服务器建立连接,然后返回一个响应。

第四个relay阶段,此时浏览器会正常发送请求,然后代理服务器接收到请求之后直接转发给真正的服务器,真正的服务器返回的响应也经过代理服务器转发到浏览器这边。socks5代理服务器实际上并不关系流量的细节,可以是HTTP流量也可以是其他的TCP流量。

1.TCP echo server

在go语言中实现一个简单的TCP echo server。使用简单的逻辑,发送什么就返回什么,方便测试。

在main函数中使用net.listen监听一个端口,返回一个server。然后在一个死循环中反复accept请求,成功之后就会返回一个连接。接下来在一个process函数里处理这个连接。

process函数的实现,先添加一个defer connection.close防止资源泄露,接下来使用bufio.NewReader来创建一个带缓冲的只读流,带缓冲的流可以减少底层系统的调用次数,且具有更多的工具函数可以读取数据,可以使用readbyte来读取单个字节,再写进去连接。

main函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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) //go关键字代表启动一个goroutine,这里的goroutine可暂时类比为一个子线程,但是开销比子线程小很多,能够轻松处理上万的并发
}
}

process函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func process(conn net.Conn) {
defer conn.Close()
reader := bufio.NewReader(conn)
for {
b, err := reader.ReadByte()
if err != nil {
break
}
_, err = conn.Write([]byte{b})
if err != nil {
break
}
}
}

执行nc命令测试服务器,nc是linux系统下的命令,windows系统需要额外安装。

1
nc 127.0.0.1 1080

输入什么服务器就会返回什么。

2.auth

认证阶段,这一部分会比较复杂。实现一个空的auth函数,在process函数里调用。

认证阶段的逻辑,第一步,浏览器会向代理服务器发送一个包。这个包有三个字段

第一个字段version,协议版本号,固定是5。

第二个字段methods,认证的方法数目。

第三个字段,每个method的编码,0表示不需要认证,2表示用户名密码认证。

使用readbyte读取版本号,不是socks5直接返回报错,再读取method size(同样是一个字节)。创建一个相应长度的slice,用io.ReadFull填充信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const socks5Ver = 0x05 //socks5的版本号
func auth(reader *bufio.Reader, conn net.Conn) (err error) {
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)
_, err = conn.Write([]byte{socks5Ver, 0x00})
if err != nil {
return fmt.Errorf("write failed:%w", err)
}
return nil
}

此时,代理服务器还需要返回一个response,返回包有两个字段,一个是version,一个是method,也就是鉴传方式。当前只考虑实现不需要鉴传的方式,即00。可以使用curl命令测试效果。

1
curl --socks5 127.0.0.1:1080 -v http://www.baidu.com

curl命令还不能成功,因为协议还没有完善。查看日志,version和method可以正常打印,说明目前协议实现正确。

3.请求阶段

请求阶段,读取携带URL或者IP地址+端口的包。实现一个和auth函数类似的connect函数,同样在process中调用。

请求阶段的逻辑,浏览器会发送一个包,包里有如下6个字段

version版本号,是5。command请求类型,这边只支持connection请求,也就是让代理服务器建立新的TCP连接。RSV保留字段,不需要理会。atype目标地址类型,可能是IPv4 IPv6或者域名。addr地址,这个地址的长度受atype的类型的影响会有不同。port端口号,两个字节,需要逐个读取。

前四个字段共四个字节,可以一次性读取,定义一个长度为4的buffer。读取完后,第0个就是version,判断是否为socks5,第1个就是cmd,判断是否为1(1表示connection请求),第3个就是atype,如果是IPv4,则再次读取到buffer,将buffer的字节逐个以IP地址的格式保存到addr变量中。如果是host,需要先读取长度,再创建一个相应长度的buf进行填充,转换成字符串保存到addr变量。IPv6目前不考虑支持。

最后的port有两个字节,读取后按协议规定的大端字节序转换成数字。前面的buffer不会再被其他变量使用,可以直接复用内存,创建一个临时slice,长度为2。接下来把IP地址和端口号打印出来。

收到浏览器的请求包之后,需要返回一个包。这个包里有很多字段,但大部分不会使用。

第一个是版本号socks5,第二个是返回类型,成功的话就返回0,第三个是保留字段,填0即可,第四个字段是atype地址类型,填1。第五、六个字段暂时用不到,都填0。一共是4+4+2个字节。

编写完connect函数后,使用curl重新测试,看到IP地址和端口信息被打印出来,说明当前协议实现正确。

1
curl --socks5 127.0.0.1:1080 -v http://www.baidu.com

最后一步就是和端口建立连接,实现双向转发数据。

4.relay阶段

直接使用net.dial建立一个TCP连接。建立连接之后,不要忘记使用defer来关闭连接。

实现浏览器和下游服务器的双向数据转发。标准库的io.copy可以实现一个单向数据转发,那么可以使用两个goroutine实现双向转发。

此时存在一个问题,connect函数会立即返回并关闭连接,需要等待任意一个方向copy出错后再返回connect函数。可以使用标准库的context机制,用contextWithCancel来创建一个context,在最后等待ctx.Done,只要cancel被调用,ctx.Done就会立即返回,然后在两个goroutine里各调用一次cancal即可。

在connect函数中添加

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
dest, err := net.Dial("tcp", fmt.Sprintf("%v:%v", addr, port))
if err != nil {
fmt.Errorf("dial dst failed:%w", err)
}
defer dest.Close()

// 在写入数据完成之后
ctx, cancel := context.WithCancel(context.Backgrond())
defer cancel()
go func() {
_, _ = io.Copy(dest, reader)
cancel()
}()
go func() {
_, _ = io.Copy(conn, dest)
cancel()
}()
<-ctx.Done()

执行curl测试,返回成功

1
curl --socks5 127.0.0.1:1080 -v http://www.baidu.com

还可以在浏览器中测试代理,需要安装switchyomega插件。新建情景模式,代理服务器socks5,端口1080,保存并启用。访问网站,可以在代理服务器这边看到浏览器版本的域名和端口。

参考资料

源代码

  • 标题: Go语言基础
  • 作者: Entropy
  • 创建于 : 2023-01-26 02:24:18
  • 更新于 : 2023-04-01 07:55:52
  • 链接: https://www.entropy-tree.top/2023/01/26/golang-day1/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论