Go语言的实战案例 | 青训营笔记

156 阅读34分钟

万字笔记-Go语言的实战案例

项目1:猜谜游戏

第一个例子里面,我们会使用 Golang 来构建一个猜数字游戏。

在这个游戏里面,程序首先会生成一个个于 1到 100 之间的随机整数,然后提示玩家进行猜测。玩家每次输入一个数字,

程序会告诉玩家这个猜测的值是高于还是低于那个秘密的随机数,并且让玩家再次猜测。如果猜对了,就告诉玩家胜利并且退出程序。

生成随机数

为了生成随机数,我们需要用到 math/rand 包。

先导入 FMT 包和 math/rand 包,定义一个变量,maxnum是100。

用 rand.ntn 来生成一个随机数,再打印出这个随机数.查看发现使用rand之前需要设置随机数种子,否则每次都会生成相同的随机数序列

maxNum := 100
rand.Seed(time.Now().Unix()) //用启动的时间戳来初始化随机数种子
secretNum := rand.Intn(maxNum) //生成一个介于1-100之间的随机数
//但每次运行测试都是一样的,由于Seed使用提供的seed值将发生器初始化为确定性状态,所以都是一样的
//1、如果不使用rand.Seed(seed int64),每次运行,得到的随机数会一样,程序不停止,一直获取的随机数是不一样的;
//
//2、每次运行时rand.Seed(seed int64),seed的值要不一样,这样生成的随机数才会和上次运行时生成的随机数不一样;
fmt.Println("The secret number is", secretNum) //字符串和数字输出用逗号分隔

读取用户输入

接下来我们需要实现用户输入输出,并理解析成数字

每个程序执行的时候都会打开几个文件,stdin stdout stderr等,
stdin 文件可以用os.Stdin 来得到。

直接操作这个文件很不方便,我们会用 bufio.NewReader 把一个文件转换成一个 reader 变量。即可读的reader带缓冲的读取器流

bufio.NewReader是带缓冲的读取器 ReadString()即返回err为EOF时还含有数据 Read()就不会有这种情况,EOF单独返回

reader 变量上会有很多用来操作一个流的操作,我们可以用它的 ReadString 方法来读取一行。
如果失败了的话,我们会打印错误并能退出。

ReadString 返回的结果包含结尾的换行符,我们把它去掉,再转换成数字。如果转换失败,我们同样打印错误,退出。

当从控制台读取输入参数时,Windows平台会在这个参数末尾自动加上\r\n,而Linux平台会在这个参数末尾自动加上\n。

fmt.Println("please input your guess")
reader := bufio.NewReader(os.Stdin)
input, err := reader.ReadString('\n')
if err != nil {
fmt.Println("在读取输入的时候发生错误了,请重试", err)
return
}
//ReadString 返回的结果包含结尾的换行符,我们把它去掉,再使用Atoi转换成数字,返回数字和错误信息
input = strings.TrimSuffix(input, "\r\n")
guess, err := strconv.Atoi(input)
if err != nil {
fmt.Println("无效输入,请输入一个整数", err)
return
}
fmt.Println("you guess is", guess)

控制台输出如下

image.png

实现判断逻辑

现在我们有了一个秘密的值,然后也从用户的输入里面读到了一个值,我们来比较这两个值的大小。

如果是用户输入的值比那个秘密的值要大的话,就告诉用户你猜的值太大了,请再试一次。

如果是小了也同理,如果是相等的话,那么我们就告诉用户赢了

if guess > secretNum {
fmt.Println("你猜的数字比谜底要大,请重试")
} else if guess < secretNum {
fmt.Println("你猜的数字比谜底要小,请重试")
} else {
fmt.Println("你猜对啦!")
}

实现游戏循环

玩家只能输入一次猜测,无论猜测是否正确,程序都会突退出。为了改变这种行为,让游戏可以正常玩下去,我们需要加一个循环

我们把刚刚的代码挪到一个 for 循环里面,再把 return 改成 continue 以便于在出错的时候能够继续循环。在用户输入正确的时候 break,这样才能够在用户胜利的时候退出游戏。

整体代码为:

package main

import (
"bufio"
"fmt"
"math/rand"
"os"
"strconv"
"strings"
"time"
)

//第一个例子里面,我们会使用 Golang 来构建一个猜数字游戏。
//在这个游戏里面,程序首先会生成一个个于 1到 100 之间的随机整数,然后提示玩家进行猜测。玩家每次输入一个数字,
//程序会告诉玩家这个猜测的值是高于还是低于那个秘密的随机数,并且让玩家再次猜测。如果猜对了,就告诉玩家胜利并且退出程序。

func main() {
maxNum := 100
rand.Seed(time.Now().Unix()) //用启动的时间戳来初始化随机数种子
secretNum := rand.Intn(maxNum) //生成一个介于1-100之间的随机数
//但每次运行测试都是一样的,由于Seed使用提供的seed值将发生器初始化为确定性状态,所以都是一样的
//1、如果不使用rand.Seed(seed int64),每次运行,得到的随机数会一样,程序不停止,一直获取的随机数是不一样的;
//
//2、每次运行时rand.Seed(seed int64),seed的值要不一样,这样生成的随机数才会和上次运行时生成的随机数不一样;
//fmt.Println("The secret number is", secretNum) //字符串和数字输出用逗号分隔

/接下来我们需要实现用户输入输出,并理解析成数字
每个程序执行的时候都会打开几个文件,stdin stdout stderr等,
stdin 文件可以用os.Stdin 来得到。
直接操作这个文件很不方便,我们会用 bufio.NewReader 把一个文件转换成一个 reader 变量。即可读的reader流
reader 变量上会有很多用来操作一个流的操作,我们可以用它的 ReadString 方法来读取一行。
如果失败了的话,我们会打印错误并能退出。
ReadString 返回的结果包含结尾的换行符,我们把它去掉,再转换成数字。如果转换失败,我们同样打印错误,退出。
/
fmt.Println("请输入你猜的数字")
reader := bufio.NewReader(os.Stdin)

for {
input, err := reader.ReadString('\n')
if err != nil {
fmt.Println("在读取输入的时候发生错误了,请重试", err)
//return
continue
}
//ReadString 返回的结果包含结尾的换行符,我们把它去掉,再使用Atoi转换成数字,返回数字和错误信息
input = strings.TrimSuffix(input, "\r\n")
guess, err := strconv.Atoi(input)
if err != nil {
fmt.Println("无效输入,请输入一个整数", err)
//return
continue
}
//fmt.Println("you guess is", guess)

//数据判断
if guess > secretNum {
fmt.Println("你猜的数字比谜底要大,请重试")
} else if guess < secretNum {
fmt.Println("你猜的数字比谜底要小,请重试")
} else {
fmt.Println("你猜对啦!")
break //退出循环
}
//让游戏可以正常玩下去,我们需要加一个循环。
//把刚刚的代码挪到一个 for 循环里面,再把 return 改成 continue 以便于在出错的时候能够继续循环。
//在用户输入正确的时候 break,这样才能够在用户胜利的时候退出游戏。
}
}

控制台输出如下

image.png

项目2:在线词典

用户可以在命令行里面查询一个单词。我们能通过调用第三方的 API 查询到单词的翻译并打印出来。

这个例子里面,我们会学习如何用 go 语言来发送 HTTP 请求、解析ison,还会学习如何使用代码生成来提高开发效率

抓包

使用彩云小译的api

image.png

查询单词的请求是一个HTTP 的 post 的请求,请求的 header 的相当的复杂,有十来个。然后请求头是一个json 里面有两个字段,trans_type=en2zh代表你要你是从en语言转化成zh语言,source 就是查询的单词。

image.png

API 的返回结果里面会有 Wiki 和 dictionary 两个字段.我们需要用的结果主要在dictionary.Explanations 字段里面。其他有些字段里面还包括音标等信息。

代码生成

我们需要在 Golang 里面去发送这个请求。因为这个请求比较复杂,用代码构造很麻烦,实际上我们有一种非常简单的方式来生成代码,我们可以右键浏览器里面的 copy as curl

copy完成之后在终端粘贴一下 curl 命令,可以成功返回一大串json.(复制curl(bash))

转换成go代码 image.png

流程

将curl转换成go代码

流程

  1. 创建客户端client
  2. 创建request:把固定输入改成变量输入,使用json格式结构体,再Marshal方法实现json序列化成byte数组,bytes.NewReader把数组转成流
  3. 创建http请求,包括方法,url,request输入内容
  4. 设置请求头
  5. 发起请求调用: client.do(http返回的请求),得到 response
  6. body同样是一个流,为了避免资源泄露,加一个 defer 来手动关闭这个流
  7. 读取响应ioutil.readAll(client.do返回的response的方法体)
  8. 解析response:因为要获取response body主体里的explanation变量内容,所以要对response转成字符串.因此把do请求得到的方法体反序列化为字符串传给dictResp结构体

在js/Python 这些脚本语言里面,body 是一个字典或者 map 的结构,可以直接从里面取值。 但是golang是个强类型语言,这种做法并不是最佳实践。更常用的方式是和 request 的一样,写一个结构体,把返回的JSON 反序列化到结构体里面。但是我们在浏览器里面可以看到这个 API 返回的结构非常复杂,如果要一一定义结构体字段,非常繁琐并且容易出错。 于是选择代码生成:把json字符串粘贴进去,这样我们就能够生成对应结构体。在某些时刻,我们如果不需要对这个返回结果,做很多精细的操作,我们可以选择转换嵌套,能让生成的代码更加紧凑。

image.png 9. 选择单词和他的音标来输出,for range循环迭代返回的字符串里的Explanations数组
到这一步可以输出award的值了

image.png

  1. 把代码的主体改成一个query 函数,查询的单词作为参数传递进来。原本的 main 函数首先通过os.Args判断一下命令和参数的个数,如果它不是两个,就打印出错误信息,退出程序。否则就获取到用户输入的单词os.Args[1],然后执行 query 函数

Go 标准库之 os(获取文件状态、获取/修改文件权限、创建、删除目录和文件、获取进程ID、设置获取环境变量):(61条消息) Go 学习笔记(44)— Go 标准库之 os(获取文件状态、获取/修改文件权限、创建、删除目录和文件、获取进程ID、设置获取环境变量)_go 获取 文件 创建时间_wohu1104的博客-CSDN博客 标准输入文件(stdin),通常对应终端的键盘; 标准输出文件(stdout)和标准错误输出文件(stderr),这两个文件都对应终端的屏幕。

完整代码

import (
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
"log"
"net/http"
"os"
)

// DictReq json格式结构体
type DictReq struct {
TransType string json:"trans_type" //转成小写
Source string json:"source"
UserId string json:"user_id"
}

// DictResp json格式结构体
type DictResp struct {
Rc int json:"rc"
Wiki struct {
} json:"wiki"
Dictionary struct {
Prons struct {
EnUs string json:"en-us"
En string json:"en"
} json:"prons"
Explanations []string json:"explanations"
Synonym []string json:"synonym"
Antonym []interface{} json:"antonym"
WqxExample []interface{} json:"wqx_example"
Entry string json:"entry"
Type string json:"type"
Related []interface{} json:"related"
Source string json:"source"
} json:"dictionary"
}

// 改成翻译单词的函数,在main方法对它进行调用
func query(word string) {
client := &http.Client{} //这里创建的时候其实可以指定很多参数,包括比如请求的超时(指定发起请求最大的超时,是否使用cookie 等
//没有指定就代表不限制时间

//把json变量初始化,再序列化成buf数组(byte数组),再变成制度的流
request := DictReq{TransType: "en2zh", Source: word}
buf, err := json.Marshal(request)
if err != nil {
log.Fatal(err)
}
//var data = strings.NewReader({"trans_type":"en2zh","source":"award"})
var data = bytes.NewReader(buf)
//创建http post请求
//第一个参数是http 方法 POST,第二个参数是 URL,最后一个参数是 body,
//body因为可能很大,为了支持流式发送,是一个只读流,这样只需要创建很少的内存。我们用了 strings.NewReader 来把字符串转换成一个流。这样我们就成功构造了一个 HTTP request ,
req, err := http.NewRequest("POST", "api.interpreter.caiyunai.com/v1/dict", data)
if err != nil {
log.Fatal(err)
}

//设置请求头
req.Header.Set("authority", "api.interpreter.caiyunai.com")
req.Header.Set("accept", "application/json, text/plain, /")
req.Header.Set("accept-language", "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6")
req.Header.Set("app-name", "xy")
req.Header.Set("content-type", "application/json;charset=UTF-8")
req.Header.Set("device-id", "b0eac6f358c5e4055ca86ab025610d7b")
req.Header.Set("origin", "fanyi.caiyunapp.com")
req.Header.Set("os-type", "web")
req.Header.Set("os-version", "")
req.Header.Set("referer", "fanyi.caiyunapp.com/")
req.Header.Set("sec-ch-ua", "Microsoft Edge";v="113", "Chromium";v="113", "Not-A.Brand";v="24")
req.Header.Set("sec-ch-ua-mobile", "?0")
req.Header.Set("sec-ch-ua-platform", "Windows")
req.Header.Set("sec-fetch-dest", "empty")
req.Header.Set("sec-fetch-mode", "cors")
req.Header.Set("sec-fetch-site", "cross-site")
req.Header.Set("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36 Edg/113.0.1774.35")
req.Header.Set("x-authorization", "token:qgemv4jr1y38jyq6vhvi")

//真正发起请求
//调用 client.do request,得到 response
//如果请求失败的话那么这个 error 会返回非 nil,会打印错误并且退出进程。
resp, err := client.Do(req)
if err != nil { //js解析失败,断网等情况
log.Fatal(err)
}

//response 有它的 HTTP 状态码,response header和body。
//body同样是一个流,在golang里面,为了避免资源泄露,你需要加一个 defer 来手动关闭这个流,
//这个 defer 会在这个函数运行结束之后从下往上去执行。
//接下来我们是用ioutil.ReadAll 来读取这个流,能得到整个body。我们再用 print 打印出来。
//
defer resp.Body.Close()

//读取响应
bodyText, err := ioutil.ReadAll(resp.Body)
if err != nil {
log.Fatal(err)
}

//检测返回状态码,防止请求出错
if resp.StatusCode != 200 {
//出错时候返回状态码和报文,方便我们找到问题
//不加这个检测的话,出错了反序列化返回的就是一个空结构,无法知道问题所在
fmt.Println("错误状态码:", resp.StatusCode, "body", string(bodyText))
}

//fmt.Printf("%s\n", bodyText)
//打印body先把json格式反序列化到这个response结构体当中,此时结构体为字符串,方便输出指定内容
var dictResp DictResp
//在进行反序列化操作时:第二个参数应该传地址&
err = json.Unmarshal(bodyText, &dictResp)
if err != nil {
log.Fatal(err)
}
//fmt.Printf("%#v\n", dictResp)

//输出单词和音标
fmt.Println(request.Source, "UK:", dictResp.Dictionary.Prons.En, "US:", dictResp.Dictionary.Prons.EnUs)

//循环explanations输出单词解释
/for i := range dictResp.Dictionary.Explanations {
fmt.Println(dictResp.Dictionary.Explanations[i])
}
/ //或者 range 返回索引和对应位置的值
for _, item := range dictResp.Dictionary.Explanations {
fmt.Println(item)
}
//解析出来的就是json字符串
/*{"rc":0,"wiki":{},
"dictionary":{"prons":{"en-us":"[\u0259\u02c8w\u0254rd]",
"en":"[\u0259\u02c8w\u0254\u02d0d]"},
"explanations":["vt.\u628a\u2026\u5956\u7ed9,\u6388\u4e88;\u5224\u7ed9,\u5224\u5b9a",
"n.\u5224\u5b9a;\u5ba1\u5224;\u5956\u54c1;\u88c1\u5b9a\u989d"],
"synonym":["reward","prize","medal","trophy","grant"],
"antonym":[],
"wqx_example":[],
"entry":"award",
"type":"word",
"related":[],
"source":"wenquxing"}}
*/
//但这样输入是固定的,需要是变量输入英文来翻译
//生成json变量的方式是生成结构体,这个结构体就能用JSON.marshaler 去序列化,变成一个J数组
}

// os.Args获取命令行参数,参数有两个 分别是C:\Users\xsn\AppData\Local\Temp\go-build233108084\b001\exe\generate.exe 和单词award
// 如果命令后面没有接单词就报错,即命令加参数不等于2时候
func main() {
if len(os.Args) != 2 {
fmt.Fprintf(os.Stderr, 输入错误)
os.Exit(1)
}
for _, item := range os.Args {
fmt.Println(item)
}
word := os.Args[1]
query(word)
}

控制台测试:

image.png

项目3:SOCKS5代理服务器

一提到代理服务器,可能会想到的是翻墙。不过很遗憾的是,socks5 协议它虽然是代理协议,但它并不能用来翻墙,它的协议都是明文传输。

这个协议历史比较久远,诞生于互联网早期。它的用途是,比如某些企业的内网为了确保安全性,有很严格的防火墙策略,但是带来的副作用就是哪怕是管理员,访问某些资源都会很麻烦。socks5 相当于在防火墙开了个口子,让授权的用户可以通过单个端口去访问内部的所有资源

实际上很多翻墙软件,最终暴露的也是一个 socks5 协议的端口,给一些浏览器之类的使用。

如果有同学开发过爬虫的话,就知道,在爬取过程中很容易会遇到IP访问频率超过限制,就会报错。这个时候很多人就会去网上找一些代理 P 池,这些代理 P 池里面的很多代理的协议就是 socks5.

实现的效果:

启动这个程序,然后在浏览器里面配置使用这个代理,此时我们打开网页代理服务器的日志,会打印出你访问的网站的域名或者 IP ,这说明我们的网络流量是通过这个代理服务器的。我们也能在命令行去测试我们的代理服务器。我们可以用 curl -socks5 + 代理服务器地址,后面加一个可访问的 URL, 如果代理服务器工作正常的话,那么 curl 命令就会正常返回。

socks5原理

image.png 正常浏览器访问一个网站,不经过代理服务器的话,就是先和对方的网站建立 TCP 连接,然后三次握手,握手完之后发起 HTTP 请求,然后服务返回 HTTP 响应

如果设置代理服务器之后,流程会变得复杂一些。首先是浏览器和 socks5 代理建立 TCP 连接,代理再和真正的服务器建立 TCP 连接.

这里可以分成四个阶段,握手阶段、认证阶段、请求阶段、relay 阶段

第一个握手阶段(也叫协商阶段),浏览器会向 socks5 代理发送请求,发送的报文内容包括一个协议的版本号(一般是v5),还有支持的认证的种类。

第二个阶段是(通过协商之后的)认证阶段,socks5 服务器会选中一个认证方式,返回给浏览器(建议用户选择什么进程方式)。如果返回的是 00 的话就代表不需要认证,返回其他类型的话会开始认证流程,这里就不对认证流程进行概述了。因为要实现的是不加密的代理

第三个阶段是请求阶段,认证通过之后浏览器会 向socks5 服务器发起请求,即下一个报文。其主要信息包括版本号,请求的类型,一般主要是 connection 请求,就代表代理服务器要和某个域名、或者某个IP 地址、某个端口建立 TCP 连接。代理服务器收到之后,会真正和后端服务器建立TCP连接,然后返回一个响应,socks5再返回状态给客户端,告诉用户浏览器我成功建立连接了

第四个阶段是 relay (传递)阶段。此时浏览器会正常发送请求,然后代理服务器接收到请求之后,会直接把请求转换到真正的服务器上。如果真正的服务器返回响应的话,也会把响应转发到浏览器这边。实际上代理服务器并不关心流量的细节,可以是 HTTP流量,也可以是其它 TCP 流量。

TCP echo server---协商阶段

第一步,先在 go 里面写一个简单的 TCP echo server。该程序作为代理器接收client传过来的请求比如hello,代理器创建读取连接的缓冲流,一个个读取发送过来的字符h,e,l...再合并成大的,再写入要返回的连接中,就能做到发送什么回复什么

为了方便测试,server 的工作逻辑很简单,你给他发送啥,他就回复啥,大概代码会长这样子:

package main

import ( "bufio" "log" "net" )

// 协商-》请求(连接)-》传递delay
func main() {
// net.listen 去监听一个端口,会返回一个 server
server, err := net.Listen("tcp", "127.0.0.1:1080")
if err != nil {
panic(err)
}
//在一个死循环里面,每次去 accept 一个请求,成功就会返回一个连接
for {
client, err := server.Accept()
if err != nil {
log.Printf("接收失败%v", err)
continue //继续死循环
}
//注意这前面会有个 go 关键字,这个代表启动一个 goroutinue,可以暂时类比为其他语言里面的启动一个子线程去处理这个连接。只是这里的 goroutinue 的开销会比子线程、子进程要小很多,可以很轻松地处理上万的并发。
go process(client) //返回连接
}
}

// 在一个 process 函数里面去处理这个连接
// connection 请求,代表代理服务器要和某个域名、或者某个IP 地址、某个端口建立 TCP 连接
func process(conn net.Conn) {
//在这个函数退出的时候要把这个连接关掉,否则会有资源的泄露
defer conn.Close()
//创建一个带缓冲的只读流
reader := bufio.NewReader(conn) //带缓冲的流的作用是,可以减少底层系统调用的次数

//这里为了方便是一个字节一个字节的死循环读取,但是底层可能合并成几次大的读取操作,使得大的读取操作能瞬间返回
//并且带缓冲的流会有更多的一些工具函数用来读取数据 我们可以简单地调用那个 readbyte 函数来读取单个字节。再把这一个字节写进去连接。
for {
readByte, err := reader.ReadByte()
if err != nil {
break
}
//把字节写入返回的连接中,正常写入slice切片,这里用的是字节读取就写入字节,同时方括号byte包装一下完成字符转换
_, err = conn.Write([]byte{readByte})
if err != nil {
break
}
}
}

使用nc命令:执行程序之后,输入nc 127.0.0.1 1080,这个命令可以直接和某个IP端口去建立一个TCP连接

Netcat是一个常用的网络工具,也称为nc,是一种基于命令行的网络连接工具,可以在不同的计算机之间建立TCP或UDP连接。Netcat功能强大,简单易用,可以实现多种网络通信任务,包括端口扫描、文件传输、远程控制、端口转发等。 Netcat支持多种传输协议,包括TCP和UDP,可以实现基于TCP或UDP的简单网络服务,如HTTP、SMTP、POP3、SSH等。同时,Netcat还支持IPV6协议,可以在IPV6网络中进行通信。 Netcat还具有一些高级功能,如端口扫描和端口转发。端口扫描可以用于检测目标主机的端口开放情况,同时还可以扫描已知的网络服务,如FTP、HTTP、SMTP等。端口转发可以在不同的计算机之间转发网络流量,实现远程访问和控制。 Netcat还可以用于文件传输,可以将文件从一个计算机传输到另一个计算机,也可以通过网络传输文件。此外,Netcat还可以用于远程控制和监视,可以通过远程终端访问目标主机,查看和控制目标主机的终端会话。 Netcat是一种功能强大的网络工具,可以用于多种网络通信任务。它简单易用,支持多种传输协议,可以在不同的操作系统上使用,是网络系统管理、安全测试、网络协议开发等领域的必备工具之一。

nc 127.0.0.1 1080命令在windows平台无法直接执行。

解决

下载netcat 1.12,将解压后的文件夹路径添加到系统环境变量path后,重新打开powershell/cmd执行nc 127.0.0.1 1080命令。

image.png

auth授权---认证阶段

接下来要开始实现协议的第一步,认证阶段, 从这一部分开始会变得比较复杂。

我们实现一个空的 auth 函数,在 process 函数里面调用替代for循环读取连接字符,再来编写 auth 函数的代码。

我们回忆一下认证阶段的逻辑,首先第一步的话,浏览器会给代理服务器发送一个报文,他有三个字段,第一个字段,version (字节数1)也就是协议版本号 ,固定是 5,socks5为0x05; 第二个字段nmethods(字节数1),支持认证的方法数目; 第三个字段methods(字节数1-255),对应的每个 method的编码,nmethods的值为多少,methods就有多少个字符。RFC预定了一些值的内容,例如:0代表 不需要认证,2 代表用户名密码认证

前两个都是一个字节的,所以可以用read bytes读取。

先把版本号读出来,然后如果版本号不是 socket 5 的话直接报错。接下来我们再读取 method size。对于方法编码methods需要我们去make 一个相应长度的一个 slice ,用 io.ReadFull 把读取到的字节填充进去。

按照协议需要返回一个报文,告诉浏览器我们版本号和选择什么认证方式

用 curl 命令测试一下当前版本的效果:curl --socks5 127.0.0.1:1080 -v www.baidu.com

image.png

请求阶段-》后端服务器通过代理器告诉用户浏览器我成功建立连接了

代码读取浏览器发送的报文,其中携带了 URL 或者 IP 地址+端口,然后把它打印出来。

我们实现一个和 auth 函数类似的 connect 函数,同样在 process 里面去调用。再来实现 connect 函数的代码。报文里面包含如下6个字段

image.png

我们来回忆一下请求阶段的逻辑。

浏览器会发送一个包,version 版本号,还是 5。 command ,代表请求的类型,我们只支持 connection 请求,也就是让代理服务建立新的TCP连接。 RSV 保留字段,不理会,默认为0(0x00)。 atype 就是目标地址类型,可能是IPV4 ,IPV 6 或者域名 .

这里0x01代表IPV4的地址,DST.ADDR就为4个字节 0x03代表域名,此时DST.ADDR是一个可变长度的域名 下面是 dst.addr,这个地址的长度是根据 atype的类型而不同的 (第一个字节是长度,后边的n个字节就是域名) dst.port 端口号,两个字节

我们需要逐个去读取这些字段。而前四个字段总共四个字节,虽然可以用READER,ReadByte读取,但我们也可以一次性把它读出来。此时定义一个长度为 4的buffer缓冲区 然后用io.readfull把它填充满。读满之后,再对每个字段验证他的合法性

go整型和字节数组之间的转换

主机字节序 主机字节序模式有两种,大端数据模式和小端数据模式,在网络编程中应注意这两者的区别,以保证数据处理的正确性;例如网络的数据是以大端数据模式进行交互,而我们的主机大多数以小端模式处理,如果不转换,数据会混乱 一般来说,两个主机在网络通信需要经过如下转换过程:主机字节序 —> 网络字节序 -> 主机字节序

大端小端区别

大端模式:Big-Endian就是高位字节排放在内存的低地址端,低位字节排放在内存的高地址端

低地址 --------------------> 高地址

高位字节 低位字节

小端模式:Little-Endian就是低位字节排放在内存的低地址端,高位字节排放在内存的高地址端

低地址 --------------------> 高地址

低位字节 高位字节

高位字节和低位字节概念

例如在32位系统中,257转换成二级制为:00000000 00000000 00000001 00000001,其中

00000001 | 00000001

高位字节 低位字节

int和byte转换

在go语言中,byte其实是uint8的别名,byte 和 uint8 之间可以直接进行互转。uint8目前只能将0~255范围的int转成byte。因为超出这个范围,go在转换的时候,就会把多出来数据扔掉;

举例将int32转成byte类型,我们需要一个长度为4的[]byte数组

// 大端模式:数字转成字节数组
func f2() {
var v2 uint32
var b2 [4]byte
v2 = 257 //二进制为| 00000000 | 00000000 | 00000001 | 00000001 |
//uint32强转为uint8,就扔掉高位多余的数字,留下最低字节00000001
b2[3] = uint8(v2) //1
//右移8位数据,最低位00000001被舍弃,再取现在的最低位00000001
b2[2] = uint8(v2 >> 8)
//右移16位,留下倒数第三个,再取最低位
b2[1] = uint8(v2 >> 16)
b2[0] = uint8(v2 >> 24)
fmt.Println(b2) //[0 0 1 1]
}

// 小端模式:数字转成字节数组
// 小端刚好和大端相反,所以在转成小端模式的时候,只要将[]byte数组的下标首尾对换一下位置就可以了
func f3() {
var v3 uint32
var b3 [4]byte
v3 = 257
b3[0] = uint8(v3) //1
//右移8位数据,最低位00000001被舍弃,再取现在的最低位00000001
b3[1] = uint8(v3 >> 8)
//右移16位,留下倒数第三个,再取最低位
b3[2] = uint8(v3 >> 16)
b3[3] = uint8(v3 >> 34)
fmt.Printf("%v\n", b3) //[1 1 0 0]
}

//大端模式下:字节数组转成数字,拿32位数字举例

func BytesToUIntBigEndian() {
var n uint32
b := [4]byte{0, 0, 1, 1}
u := binary.BigEndian.Uint32(b[:4])
fmt.Println(u) //257
binary.Read(bytes.NewBuffer(b[:4]), binary.BigEndian, &n)
fmt.Println(n) //257

//字节数组转成整型
var n2 int32
binary.Read(bytes.NewBuffer(b[:4]), binary.BigEndian, &n2)
fmt.Println(n2) //257
}

//小端模式下,只需要把大端模式的BigEndian改成LittleEndian即可,答案一样

后端服务器返回一个包给代理,代理返回给浏览器,包内容如下:

image.png

请求阶段结束,正常打印出传递的IP地址和端口

image.png

relay阶段:真正和端口建立连接,双向转发数据

我们直接用 net.dial 建立一个 TCP 连接。

建立完连接之后,我们同样要加一个 defer 来关闭连接

接下来需要建立 浏览器 和 后端服务器的双向数据

转发标准库的 io.copy 可以实现一个单向数据转发,会把src只读流的数据用一个死循环逐步拷贝到dst这个可写流Writer里。双向转发的话,需要启动两个goroutinue.

image.png

现在有一个问题,connect 函数会立刻返回,返回的时候连接就被关闭了,需要等待任意一个方向copy出错的时候,再返回 cnnect 函数

//启动goroutine是不耗费时间的,正常情况下上边的执行完毕会直接到最后一行return nil,程序返回,连接也就被关闭了
//但是我们需要等待任意一个拷贝失败,就代表可能某一方关闭连接了,此时才终止整个连接,而不是被强制关闭连接

文里可以使用到标准库里面的一个 context 机制,用 context的with cancel 来创建一个context.在最后等待 ctx.Done0 ,只要 cancel 被调用,ctxDone就会立刻返回,然后在上面的两个 goroutinue 里面调用一次 cancel 即可

代理服务器完工

完整代码

package main

import (
"bufio"
"context"
"encoding/binary"
"errors"
"fmt"
"io"
"log"
"net"
)

// 协商-》请求(连接)-》传递delay
func main() {
// net.listen 去监听一个端口,会返回一个 server
server, err := net.Listen("tcp", "127.0.0.1:1080")
if err != nil {
panic(err)
}
//在一个死循环里面,每次去 accept 一个请求,成功就会返回一个连接
for {
client, err := server.Accept()
if err != nil {
log.Printf("接收失败%v", err)
continue //继续死循环
}
//注意这前面会有个 go 关键字,这个代表启动一个 goroutinue,可以暂时类比为其他语言里面的启动一个子线程去处理这个连接。只是这里的 goroutinue 的开销会比子线程、子进程要小很多,可以很轻松地处理上万的并发。
go process(client) //返回连接
}
}

// 创建版本号常量值
const socks5Ver = 0x05
const cmdBind = 0x01
const atypIPV4 = 0x01
const atypeHOST = 0x03
const atypeIPV6 = 0x04

// 在一个 process 函数里面去处理这个连接
// connection 请求,代表代理服务器要和某个域名、或者某个IP 地址、某个端口建立 TCP 连接
func process(conn net.Conn) {
//在这个函数退出的时候要把这个连接关掉,否则会有资源的泄露
defer conn.Close()
//创建一个带缓冲的只读流
reader := bufio.NewReader(conn) //带缓冲的流的作用是,可以减少底层系统调用的次数

//认证阶段
err := auth(reader, conn)
if err != nil {
log.Printf("浏览器远程地址:%v 认证失败:%v", conn.RemoteAddr(), err)
return
}
//log.Println("认证成功")
//请求阶段,打印浏览器发送的url/IP端口的报文
err = connect(reader, conn)
if err != nil {
log.Printf("浏览器远程地址:%v 认证失败:%v", conn.RemoteAddr(), err)
return
}
//这里为了方便是一个字节一个字节的死循环读取,但是底层可能合并成几次大的读取操作,使得大的读取操作能瞬间返回
//并且带缓冲的流会有更多的一些工具函数用来读取数据 我们可以简单地调用那个 readbyte 函数来读取单个字节。再把这一个字节写进去连接。
/for {
readByte, err := reader.ReadByte()
if err != nil {
break
}
//把字节写入返回的连接中,正常写入slice切片,这里用的是字节读取就写入字节,同时方括号byte包装一下完成字符转换
_, err = conn.Write([]byte{readByte})
if err != nil {
break
}
}
/
}

func auth(reader *bufio.Reader, conn net.Conn) (err error) {
//读取报文(以缓冲流传入)前两个单字节的字段:版本号和支持认证的方法数目
ver, err := reader.ReadByte()
if err != nil {
return fmt.Errorf("读取版本号失败:%w", err)
}
if ver != socks5Ver {
return fmt.Errorf("不支持该版本号:%v", ver)
}

nmethod, err := reader.ReadByte()
if err != nil {
return fmt.Errorf("读取版本号失败:%w", err)
}

//支持认证的方法的编码,个数同nmethod,所以用切片存储
method := make([]byte, nmethod)
//用 io.ReadFull 把读取的编码填充进切片里
_, err = io.ReadFull(reader, method)
if err != nil {
return fmt.Errorf("读取版本号失败:%w", err)
}
log.Println("版本号:", ver, "方法编码", method)
//按照协议需要返回一个报文,告诉浏览器我们版本号和选择什么认证方式(socks代理器不采取认证,实现的是不加密的代理)
conn.Write([]byte{socks5Ver, 0x00})
if err != nil {
return fmt.Errorf("读取版本号失败:%w", err)
}
//
return nil
}

/*
VER 版本号,socks5的值为0x05
CMD 0x01表示CONNECT请求
RSV 保留字段,值为0x00
ATYP 目标地址类型,DST.ADDR的数据对应这个字段的类型0x01表示IPv4地址,DST,ADDR为4个字节,0x03表示域名,DST.ADDR是一个可变长度的域名
DST.ADDR 一个可变长度的值
DST.PORT 目标端口,固定2个字节
*/
//请求阶段,浏览器发送的报文,携带了 URL 或者 IP 地址+端口,然后把它打印出来,
//代理服务器收到之后,会真正和后端服务器建立TCP连接,然后返回一个响应,socks5再返回状态给客户端,告诉用户浏览器我成功建立连接了
func connect(reader *bufio.Reader, conn net.Conn) (err error) {
//使用切片存储读取的前四个单字节字段
buffer := make([]byte, 4)
_, err = io.ReadFull(reader, buffer)
if err != nil {
return fmt.Errorf("读取请求头失败:%w", err)
}
//定义四个字段
ver, cmd, atype := buffer[0], buffer[1], buffer[3]
if ver != socks5Ver {
return fmt.Errorf("这是不支持的版本号(非5):%w", err)
}
if cmd != cmdBind {
return fmt.Errorf("这是不支持的请求类型(非connection):%w", err)
}

//根据atype值决定addr是可变还是定长(使用switch代替if
addr := ""
switch atype {
//用io.readFull把buffer缓冲区填充满
case atypIPV4:
//由于这个缓冲区是四个字节,所以可以用它存放地址
_, err := io.ReadFull(reader, buffer)
if err != nil {
return fmt.Errorf("读取目标地址类型失败(此时为IPv4):%w", err)

}
//打印出地址
addr = fmt.Sprintf("%d,%d,%d,%d", buffer[0], buffer[1], buffer[2], buffer[3])
case atypeHOST:
//此时ADDR是一个可变长度的域名,就不能用缓冲区存放了,
//照例先读一个字节,再创建切片,再readfull填充切片
hostSize, err := reader.ReadByte()
if err != nil {
return fmt.Errorf("读取目标主机(后端服务器)地址字节长度失败:%w", err)
}
host := make([]byte, hostSize)
_, err = io.ReadFull(reader, host)
if err != nil {
return fmt.Errorf("读取主机服务器失败:%w", err)
}
//把切片转为字符串输出
addr = string(host)
//还有一个IPv6的情况,也是固定长度的地址,但用的比较少,就不实现了
case atypeIPV6:
return errors.New("IPv6是不支持的")
default:
return errors.New("无效的目标地址类型")
}

//还剩两个字节的端口号
//复用四个字节的缓冲区,裁剪成2个字节的缓冲区
_, err = io.ReadFull(reader, buffer[:2]) //0,1
if err != nil {
return fmt.Errorf("读取端口号失败:%w", err)
}
//按协议规定的大端字节序将[]byte字节数组转换成数字
//由于byte数组有两个字节,一个字节8bit,所以转成uint16,代表16位数的数字存储数据
port := binary.BigEndian.Uint16(buffer[:2])

//接收到浏览器的IP端口后就先进入delay阶段:和后端服务器建立TCP连接,真正发送请求,双向交换数据
//该方法用tcp协议把对应的IP或者端口或者域名去建立tcp连接
dest, err := net.Dial("tcp", fmt.Sprintf("%v:%v", addr, port))
//此时浏览器会正常发送请求,然后代理服务器接收到请求之后,会直接把请求转换到真正的服务器
if err != nil {
return fmt.Errorf("会话地址建立失败:%w", err)
}
//建立连接之后没有错误,就关闭连接
defer dest.Close()

//打印出地址和端口号,交互地址与端口号进行连接
fmt.Println("dialog(会话):", addr, port)

/*
收到浏览器的这个请求包之后,我们需要返回一个回包,这个包有很多字段,但其实大部分都不会使用。
第一个是版本号还是socks 5。
第二个,就是返回的类型,成功就返回0
第三个是保留字段 填0
第四个 atype 地址类型填1(IPv4)
第五个,第六个暂时用不到,都填成 0,其中addr四个字节,port两个字节
一共 4 + 4 +2个字节,后面6个字节都是 0 填充。*/
//拼成byte数组,直接写到连接进去
_, err = conn.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
if err != nil {
return fmt.Errorf("响应的报文写入连接失败:%w", err)
}

//context 机制确保连接关闭才终止整个连接
ctx, cancel := context.WithCancel(context.Background())
//关闭连接
defer cancel()

//建立浏览器与后端服务器的双向转换
//启动两个goroutine
go func() {
//把只读流从用户浏览器拷贝数据到底层服务器 writer,reader
io.Copy(dest, reader)
//子进程出错时调用cancel函数,代表关闭连接
cancel()
}()
go func() {
//拷贝底层服务器数据到要响应的连接
//从底层服务器拷贝数据到用户浏览器
io.Copy(conn, dest)
cancel()
}()
//启动goroutine是不耗费时间的,正常情况下上边的执行完毕会直接到最后一行return nil,程序返回,连接也就被关闭了
//但是我们需要等待任意一个拷贝失败,就代表可能某一方关闭连接了,此时才终止整个连接,而不是被强制关闭连接
//采用context机制

//等待context执行完成,代表连接关闭,执行完成的时机就是cancel函数被调用的时机
<-ctx.Done()
return nil

}

执行效果:出错:

image.png

1684141274830.png

浏览器测试

试着在浏览器里面再测试一下,在浏览器里面测试代理需要安装switchomega 插件,

然后里面新建一个情景模式,代理服务器选 socks5,端口 1080保存并启用。

此时能正常地访问网站,代理服务器这边会显示出浏览器版本的域名和端口

image.png

在该网页测试,测试如下

image.png

image.png

个人总结

这是第一次接触go项目,有点难,跟着敲边思考,这门课程虽然是有四十分钟,但是敲代码查资料思考的时间用了很多,第一次面对有点棘手,很多go语言都是第一次见,都不理解,就需要一直查资料,虽然很辛苦,但是收获很多 我相信我会入门Go语言的!!!