这是我参与「第五届青训营 」伴学笔记创作活动的第 1 天,主要还是加强了对Go基本语法以及标准库的一些应用,本文可能有点啰嗦,可以直接划到想要看的部分
变量
流程控制
数组
切片
map
函数
结构体
实战案例
猜谜游戏
在线词典
socks5代理
Go简介
Go语言是一门性能高,而且天然支持高并发的一门编程语言。在当今多核的时代,相对于其他编程语言的多线程,Go语言的协程能够更好地去利用计算机上的硬件资源。而且Go语言语法以及书写规范较其他编程语言来说比较简单,上手速度比较快,但是若想彻底掌握基本语法,也离不开多次的实践、试错与总结
Go语言基本语法
变量
在Go语言里面,定义变量大体来说有两种方法,一种是使用 :,另一种是使用var关键字,即
a := 1
var a = 1
无论使用哪种方法,只要右边有值,都可以不需要显式声明值类型,Go编译器会自动推断。需要特别注意的是,使用 :这种声明方式声明的变量,只能用在函数体内,不用能用全局变量的声明,而且冒号左边必须至少有一个未声明过的变量,否则会报错。全局变量的声明只能使用var关键字,在全局变量多的时候可以使用以下简便写法
var(
a = 1
b
)
流程控制
Go语言中的if-else以及for循环都不需要写括号,而且在Go中不存在某些编程语言中的while和do循环,只有一种循环,但能使用for循环达到while循环和do循环的效果。
Go中的switch-case用法比较灵活,case后面不仅可以跟常量,也可以跟表达式。若switch后面不跟表达式或者常量,则用法相当于if-else
数组
Go语言中的数组和其他编程语言比较类似,在Go语言中判断两个数组是否相等要判断数组的类型和数组长度是否相同,若两者都相同,才能判断两个数组相同
Go语言中数组的定义有以下几种写法
var numArray01 [3]int=[3]int {1,2,3}
var numArray02=[3]int {1,2,3}
var numArray03=[...]int {6,7,8} //...让系统判断数组大小
//可以指定元素对应的下标
var names=[3]string {1:"tom", 0:"jack", 2:"marry"}
可以使用len()这个内置函数获取数组、切片、channel的长度
切片
切片是Go语言中使用较频繁的数据类型,可以理解成C++中的vector,也是一个长度可动态变化的数组,它是数组的描述符。Go中对于切片的使用大致如下:
var arr = [5]int{1,2,3,4,5} //先定义一个数组
slice:=arr[1:3] //对数组进行切片操作,此时slice={2,3}
slice := make([]int, 5)
使用make函数来声明切片不需要导入额外的包,make函数是内置函数。切片在使用前必须通过make函数来创建空间,或者指向一个已经存在的数组。上述用法中的5表示创建一个长度为5的切片,该切片的长度在后续使用是能够动态变化的,使用append()函数来从切片尾部插入数据
map
Go中的map数据结构其实就是其他编程语言中的哈希表,即存储一个键和一个值,且键不能重复。Go中的map用法如下
//方式1
var name1 map[string]int
//方式2
name1 := make(map[string]int) //更推荐这种
map和切片一样,在使用前都需要使用make函数进行初始化。map的语法格式为
map[类型1]类型2,类型1是键的数据类型,类型2是值的数据类型,取值的时候有两种方式
name1 := make(map[string]int)
key := "小明"
//方式1
value := name1[key]
//方式2
value, ok := name1[key]
if ok!=true {
return
}
两种方式的区别在于是否有第二个返回值ok,可以通过第二个返回值来判断map中是否存储了key对应的value,若存在则ok的值为true。
未显式赋初值的map类型变量的零值为nil。对于处于零值状态的map变量进行操作将会导致运行时panic。声明是不会分配内存的,初始化需要make,分配内存后才能赋值和使用
函数
Go里面的函数最大的特点就是它支持多返回值。定义一个函数的关键字是func,语法格式是func 函数名(参数类型)(函数返回值),比如
func max(a int, b int) int {
if a>b {
return a
}
return b
}
func getValue(name1 map[string]int, key string) (int, bool) {
value, ok := name1[key]
return value, ok
}
当函数返回值只有一个时,括号可以省略不写,当返回值在两个或以上,则需要添加括号,函数体内,多个返回值之间以逗号分割,且顺序对应函数返回值的书写顺序
结构体
Go中的结构体和C语言中的结构体有点不同,具体语法以及例子如下
type 标识符(结构体名称) struct{
field1 数据类型
field2 数据类型
}
//例如
type Student struct{
Name string
Age int
Score float32
}
由于结构体的知识感觉涉及比较多,还会涉及到许多知识比如go对指针类型的结构体变量的优化,所以这里先不作过多说明
后面的错误处理、字符串操作、字符串格式化、JSON处理,时间处理、数字解析和进程信息所涉及到的函数对应的包分别是errors、strings、fmt、encoding/JSON、time、strconv os和os/exec,使用的时候去官方文档查看即可
实战案例
猜谜游戏
这个案例原理并不复杂,大致步骤为
- 使用
math/rand包的函数Intn(n)生成随机数,为了保证每次生成的随机数不一样,需要使用该包下的函数Seed(seed int64)函数生成一个随机额种子,官方文档的解释是:使用给定的seed将默认资源初始化到一个确定的状态 - 使用包
bufio下的NewReader()创建一个具有默认大小缓冲、从r读取的*Reader结构体实例,可以理解为可以读取消息的输入流。NewReader函数中填入的参数是os包下的stdin变量,指向的是标准输入,这样才是从控制台获取输入 - 在for循环里面调用步骤2创建的Reader结构体实例的方法
ReadString("\n"),相当于从控制台一直读取消息直到遇到换行符,这个方法会返回已经读取的数据(包含了换行符) - 由于步骤3中读取的数据包含换行符,所以需要使用字符串处理的相关函数,这里需要去除尾部的换行符,所以使用的是
strings包下的TrimSuffix(s string, suffix string)方法。这个方法作用是若字符串s中包含后缀suffix,则返回去除后缀suffix后的字符串,否则直接返回字符串。 - 获取并处理完数据后,由于获取的数据是字符串,所以使用
strconv包下的Atoi(s string)方法,该方法返回字符串s对应的数字以及可能遇到的错误。若输入的s不是一个整数字符串,则返回的错误不为nil,否则返回的错误为nil,若出错了则使用continue跳过本轮循环 - 最后和生成的随机数进行比较,根据它们的大小情况输出提示信息在控制台,若步骤5得到的整数和步骤1生成的随机数相等,则退出死循环,否则则一直进行循环,不断从控制台获取输入,即不断循环步骤3到步骤6
在线词典
这个案例关键在于,如何从控制台读取想要查询的单词,然后将其序列化并将序列化后的数据和请求一起发送给的翻译引擎,最后从翻译引擎读取数据并把数据反序列化成结构体,从中取出我们想要的结果
-
构造请求结构体。打开开发者工具可以看到请求信息
可知我们需要构造一个结构体,至少包含source字段和trans_type字段,source字段是用来传递需要翻译的单词
-
从命令行参数来获取要翻译的单词,获取命令行参数要用到
os包下的Args变量,Args保管了命令行参数它是一个字符串数组,第一个是程序名,比如:命令行中输入go run main.go hello那么Args中的值为["main,go", "hello"],因此可以使用Args[1]来获取要翻译的单词
-
用获取到的单词生成一个请求结构体,并使用
encoding/JSON包下的Marshal方法把结构体序列化成byte切片 -
由于需要发送HTTP请求,于是使用
http包下的Client结构体生成一个空结构体实例,并且创建一个从序列化后的结构体读取数据的输入流 -
接着用POST这种请求方法,使用
http包下的NewRequest()方法生成一个向指定网址发送数据包的请求(返回一个Request结构体实例) -
使用
request结构体里面Header字段的Set方法来设置相应的请求头参数 -
使用客户端,即步骤4生成的
Cilent结构体实例,执行Do()方法来发送步骤5获得的请求结构体实例,返回一个Response结构体实例 -
我们所需要的数据在Response结构体的Body字段里面,所以我们需要使用
os/ioutil包下面的ReadAll()方法,该方法能够读取Body字段的所有数据 -
接着把步骤8获取到的响应数据反序列化到响应结构体里,响应结构体的字段可以通过开发者工具来查看
-
最后从该结构体中打印我们所需的数据即可
socks5代理
在实现socks5之前,需要先了解socks5的原理以及所需要读取的字段
整个过程如下
每个阶段需要的请求内容如下
1. 握手阶段
握手阶段包含协商和子协商阶段,我们把它拆分为两个分别讨论
2. 协商阶段
在这个阶段,客户端向socks5发起请求,内容如下:
+----+----------+----------+
|VER | NMETHODS | METHODS |
+----+----------+----------+
| 1 | 1 | 1 to 255 |
+----+----------+----------+
#上方的数字表示字节数,下面的表格同理,不再赘述
VER: 协议版本,socks5为0x05
NMETHODS: 支持认证的方法数量
METHODS: 对应NMETHODS,NMETHODS的值为多少,METHODS就有多少个字节。RFC预定义了一些值的含义,内容如下:
X’00’ NO AUTHENTICATION REQUIRED
X’01’ GSSAPI
X’02’ USERNAME/PASSWORD
X’03’ to X’7F’ IANA ASSIGNED
X’80’ to X’FE’ RESERVED FOR PRIVATE METHODS
X’FF’ NO ACCEPTABLE METHODS
socks5服务器需要选中一个METHOD返回给客户端,格式如下:
+----+--------+
|VER | METHOD |
+----+--------+
| 1 | 1 |
+----+--------+
当客户端收到0x00时,会跳过认证阶段直接进入请求阶段; 当收到0xFF时,直接断开连接。其他的值进入到对应的认证阶段。
3. 认证阶段
认证阶段作为协商的一个子流程,它不是必须的。socks5服务器可以决定是否需要认证,如果不需要认证,那么认证阶段会被直接略过。
如果需要认证,客户端向socks5服务器发起一个认证请求,这里以0x02的认证方式举例:
+----+------+----------+------+----------+
|VER | ULEN | UNAME | PLEN | PASSWD |
+----+------+----------+------+----------+
| 1 | 1 | 1 to 255 | 1 | 1 to 255 |
+----+------+----------+------+----------+
VER: 版本,通常为0x01
ULEN: 用户名长度
UNAME: 对应用户名的字节数据
PLEN: 密码长度
PASSWD: 密码对应的数据
socks5服务器收到客户端的认证请求后,解析内容,验证信息是否合法,然后给客户端响应结果。响应格式如下:
+----+--------+
|VER | STATUS |
+----+--------+
| 1 | 1 |
+----+--------+
STATUS字段如果为0x00表示认证成功,其他的值为认证失败。当客户端收到认证失败的响应后,它将会断开连接。
3. 请求阶段
顺利通过协商阶段后,客户端向socks5服务器发起请求细节,格式如下:
+----+-----+-------+------+----------+----------+
|VER | CMD | RSV | ATYP | DST.ADDR | DST.PORT |
+----+-----+-------+------+----------+----------+
| 1 | 1 | X'00' | 1 | Variable | 2 |
+----+-----+-------+------+----------+----------+
VER 版本号,socks5的值为0x05
CMD
0x01表示CONNECT请求
0x02表示BIND请求
0x03表示UDP转发
RSV 保留字段,值为0x00
ATYP 目标地址类型,DST.ADDR的数据对应这个字段的类型。
0x01表示IPv4地址,DST.ADDR为4个字节
0x03表示域名,DST.ADDR是一个可变长度的域名
0x04表示IPv6地址,DST.ADDR为16个字节长度
DST.ADDR 一个可变长度的值
DST.PORT 目标端口,固定2个字节
上面的值中,DST.ADDR是一个变长的数据,它的数据长度根据ATYP的类型决定。我们可以通过掐头去尾解析出这部分数据。
socks5服务器收到客户端的请求后,需要返回一个响应,结构如下
+----+-----+-------+------+----------+----------+
|VER | REP | RSV | ATYP | BND.ADDR | BND.PORT |
+----+-----+-------+------+----------+----------+
| 1 | 1 | X'00' | 1 | Variable | 2 |
+----+-----+-------+------+----------+----------+
VER socks版本,这里为0x05
REP Relay field,内容取值如下
X’00’ succeeded
X’01’ general SOCKS server failure
X’02’ connection not allowed by ruleset
X’03’ Network unreachable
X’04’ Host unreachable
X’05’ Connection refused
X’06’ TTL expired
X’07’ Command not supported
X’08’ Address type not supported
X’09’ to X’FF’ unassigned
RSV 保留字段
ATYPE 同请求的ATYPE
BND.ADDR 服务绑定的地址
BND.PORT 服务绑定的端口DST.PORT
针对响应的结构中,BND.ADDR和BND.PORT值得特别关注一下,可能有朋友在这里会产生困惑,返回的地址和端口是用来做什么的呢?
4. Relay阶段
socks5服务器收到请求后,解析内容。如果是UDP请求,服务器直接转发; 如果是TCP请求,服务器向目标服务器建立TCP连接,后续负责把客户端的所有数据转发到目标服务。
本次实践采用本机的1080端口作为代理服务器的接口因此
-
使用
net包下的Listen()方法来创建一个监听连接的Listener,并且每来一次连接都要调用一次Listener的Accept()方法,表示接收本次连接,会获得一个面向流的网络连接Conn -
由于该连接面向流,因此可以使用
bufio的NewReader()创建一个从连接读取数据的reader -
由于第一个阶段是认证阶段,所以根据认证阶段所需要获取的数据
获取对应的协议版本,认证方法数量以及认证方法
-
调用reader的
ReadByte()方法(ReadByte读取并返回一个字节),前两个字段都是各一个字节,所以调用两次该方法就能分别获取协议版本和认证方法数。然后根据获取的认证方法数创建一个缓冲区,调用io包下的ReadFull()方法,从reader读取对应的字节数填充缓冲区,最后向步骤1获取的连接写入使用的版本协议和选用的认证方法,表示返回响应给发送请求方 -
若步骤4认证通过,则会自动进行TCP的连接建立,因此直接进行发送数据。客户端发送给代理服务器的数据内容如下
可以使用一个大小为4的缓冲区一次性读取协议版本,cmd命令,保留字段以及目标地址类型并对它们进行验证
-
验证通过后继续调用
readFull()方法读取要访问的ip地址和端口号 -
在这里要先介绍一下什么是大端字节序列和小端字节序列
- 大端字节序:高位字节在前,低位字节在后,这是人类读写数值的方法。
- 小端字节序:低位字节在前,高位字节在后,即以
0x1122形式储存。
即对于0x1234567,写法如下(注:每个格子相当于一个字,一个字等于两个字节,而一个数字代表一个字节)
-
所以这里需要使用大端字节序列转换一下步骤6获取到的端口号,Go的
encoding/binary包下的BigEndian变量实现的就是大端字节序,因此调用该变量,并且调用里面的Uint16()方法来转换端口号 -
利用获得的端口号和IP对目标url发起网络连接,获取一个连接
dst -
同时在步骤4,socks5服务器收到客户端的请求后,需要返回一个响应,即需要在代理服务器(案例中的本机),向发起请求的客户端返回一个响应
-
使用步骤1获取的连接
Conn(此连接是客户端到代理服务器的连接),调用其Write()方法向客户端返回响应 -
启动两个协程,一个协程调用
io包的Copy函数把reader的数据拷贝到步骤9获取的目标url的连接dst,相当于发送了relay数据。另一个协程也调用Copy方法,只不过是把dst的响应数据拷贝到Conn中,相当于返回响应给代理服务器
至此整个代理服务器的案例就实现完成啦,小白一个~~,不知道这三个案例理解的有没有问题,希望有大佬指出其中的错误
参考文章
socks5协议详细说明:t.csdn.cn/gAAdP
理解字节序:t.csdn.cn/LMB9V