在前面我们学习Go语言的若干基础,今天将用三个简单的实践工程来巩固对知识点的理解。三个例子分别是猜谜游戏、在线词典、SOCKS5代理。话不多说,让我们开始吧!
一、猜谜游戏
(1) 随机数生成
在Go语言的math/rand包中,拥有可以生成随机数的函数——rand.Intn()。其中填入的参数为n时,生成的随机数范围为[0,n):
import (
"fmt"
"math/rand"
)
func main() {
num := rand.Intn(100)
fmt.Println(num)
}
此时在我本机跑出来的已经是随机数了,当我加上rand.Seed(time.Now().Unix())即用时间戳初始化随机数种子时,编译器提醒我们这种方式已经不被赞成了,所以我们后续不写这一句。
(2) 读取用户输入
我们一步步来分析是怎么输入的。
reader:=bufio.NewReader(os.Stdin)
io包定义了一个reader接口,该接口中包含了一个Reader函数,而bufio.Reader是带有缓冲区的io.Reader的一个实现。它会在内存中存储从底层io.Reader读取到的数据,然后先从内存缓冲区读取数据,减少io数量。设为os.Stdin则明确了输入来源为键盘键入。
str,err:=reader.ReadString('\n')
在运行read.ReadString('\n)时,程序会阻塞等待键入,输入的终止符是\n。比如我们输入49\n,得到字符串是49\r\n。我们需要的是49这个数字,因此去掉结尾的\r\n,再转换为数字即可,即:
input := strings.TrimSuffix(str, "\r\n") //去除后缀.
guess, err := strconv.Atoi(input) //转换为数字
综上,读取用户输入的代码就是:
reader := bufio.NewReader(os.Stdin)
str, err := reader.ReadString('\n')
input := strings.TrimSuffix(str, "\r\n") //去除后缀.
guess, err := strconv.Atoi(input) //转换为数字
(3) 最终程序
考虑猜随机数的功能,我们只需要写一个死循环,然后比较答案和用户猜测的大小关系即可。
package main
import (
"bufio"
"fmt"
"math/rand"
"os"
"strconv"
"strings"
"time"
)
func main() {
rand.Seed(time.Now().Unix()) //用时间戳初始化种子
fmt.Println("请猜测0~100的数字!")
ans := rand.Intn(101)
reader := bufio.NewReader(os.Stdin)
for {
str, err := reader.ReadString('\n')
input := strings.TrimSuffix(str, "\r\n") //去除后缀.
guess, err := strconv.Atoi(input) //转换为数字
if err != nil {
fmt.Println("Input Error!", err)
return
}
if guess == ans {
fmt.Println("答案正确!")
return
} else if guess < ans {
fmt.Println("猜小了!")
} else {
fmt.Println("猜大了!")
}
}
}
可正常运行:
二、在线词典
(1) 抓包
进入fanyi.caiyunapp.com 后,右击后点击检查,找到network后点击dict,对其中Request url为post的进行复制,然后打开这个网站 curlconverter.com/go/ 。可以生成请求代码。
(2) 生成代码
在扔进上面的网站后,我们就可以得到它帮我们生成的代码。
其中http.NewRequest表示创建请求,该函数会接受请求方法(POST)、URL、请求体数据。
然后一大串的req.Header.set都是请求头设置。
然后client.Do(req)才是真正的发起请求。我们可以使用ioutil.ReadAll读取响应体的数据,最后defer resp.Body.Close()关闭响应体数据流。
(3) Request Body生成和解析
然而这个时候我们输出还是混乱的。这是因为服务器返回的是json,我们要把json写进结构体里,方便我们拆解和输出。我们可以使用系统提供的json.Unmarshal()函数进行json解析。这个函数会把bodytext的反序列化结果输入结构体实例。但是我们一个个对着json造结构体是很麻烦,可以使用 oktools.net/json2go 来帮我们做这件事。
最后我们发给服务器的请求也序列化成json形式,即先写入结构体,再使用json.Marshal(request)进行json生成。
最后的核心代码如下:
package main
import (
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
"log"
"net/http"
)
type DicRequest struct {
Transtype string `json:"trans_type"`
Source string `json:"source"`
UserID string `json:"user_id"`
}
type DicResponse 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 []string `json:"antonym"`
WqxExample [][]string `json:"wqx_example"`
Entry string `json:"entry"`
Type string `json:"type"`
Related []interface{} `json:"related"`
Source string `json:"source"`
} `json:"dictionary"`
}
//核心:marshal和unmarshal.
func main() {
for {
client := &http.Client{}
var word string
fmt.Scanln(&word)
request := DicRequest{Transtype: "en2zh", Source: word}
buf, err := json.Marshal(request)
if err != nil {
log.Fatal(err)
}
data := bytes.NewReader(buf) //将json文件读入流中.
//创建请求:method、url、data(流).
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("DNT", "1")
req.Header.Set("os-version", "")
req.Header.Set("sec-ch-ua-mobile", "?0")
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.51 Safari/537.36")
req.Header.Set("app-name", "xy")
req.Header.Set("Content-Type", "application/json;charset=UTF-8")
req.Header.Set("Accept", "application/json, text/plain, */*")
req.Header.Set("device-id", "")
req.Header.Set("os-type", "web")
req.Header.Set("X-Authorization", "token:qgemv4jr1y38jyq6vhvi")
req.Header.Set("Origin", "https://fanyi.caiyunapp.com")
req.Header.Set("Sec-Fetch-Site", "cross-site")
req.Header.Set("Sec-Fetch-Mode", "cors")
req.Header.Set("Sec-Fetch-Dest", "empty")
req.Header.Set("Referer", "https://fanyi.caiyunapp.com/")
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9")
req.Header.Set("Cookie", "_ym_uid=16456948721020430059; _ym_d=1645694872")
//发送请求,
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)
}
//http状态码,200表示请求成功,响应返回.其余状态码见计网.
if resp.StatusCode != 200 {
log.Fatal("错误代码", resp.StatusCode)
}
var ans DicResponse
err = json.Unmarshal(bodyText, &ans) //将bodytext的反序列化结果输回结构体.注意传参格式和marshal()的不同之处。
if err != nil {
log.Fatal(err)
}
//输出结果:
//fmt.Printf("%#v", ans)
fmt.Println(word, "UK:", ans.Dictionary.Prons.En, "US", ans.Dictionary.Prons.EnUs)
for _, v := range ans.Dictionary.Explanations {
fmt.Println(v)
}
}
}
三、SOCKS5代理
(1) 原理
如果不使用代理的话,我们使用浏览器访问网站时,其实就是和对方的网站建立TCP连接,三次握手后发起HTTP请求,然后服务返回HTTP响应。如果使用代理的话,过程会复杂不少。
首先是浏览器和sock5代理进行建立TCP连接,而代理再和服务器建立连接。这个过程主要分为四个阶段:握手、认证、请求、Relay阶段。
- 握手阶段+认证阶段:浏览器向socks5代理发送请求,其发送的报文包括协议版本号、支持的认证方法数量;socks5服务器收到后,会选择一种认证方式返回给浏览器(若返回
0x00则不需要认证),返回其他则会开始认证流程。 - 请求阶段:认证成功后,浏览器向socks5代理发送请求,主要包括版本号、请求类型,一般为
connection,即代理和某个域名或某个ip某个端口连接,收到响应后,代理服务器和后端服务器连接,然后返回一个响应。 - relay 阶段:此时浏览器正常发送请求,代理服务器收到请求后转换到后端服务器上;返回响应后也将响应转发到浏览器。
(2) TCP echo server
我们从一个简单的服务器开始——实现你给它发什么它就给你回复什么的功能。
首先我们本机作为服务器,监听本机(127.0.0.1)的8888端口:
server, err := net.Listen("tcp", "127.0.0.1:8888")
if err != nil {
panic(err)
}
然后我们写一个死循环,等待客户端来连接,每次调用Accept(),当有客户端连上时,开一个协程和它进行通信即可。
for {
cilent, err := server.Accept() //cilent表示建立的连接
if err != nil {
log.Printf("连接失败!,%v", err)
}
go process(cilent)
}
现在实现process()函数:
func process(cilent net.Conn) {
defer cilent.Close()
reader := bufio.NewReader(cilent)
for {
data, err := reader.ReadByte()
if err != nil {
fmt.Println("客户端写入数据失败:", err)
return
}
_, err = cilent.Write([]byte{data})
if err != nil {
fmt.Println("写入失败:", err)
return
}
}
}
ReadByte()会阻塞直到客户端写入数据,服务器将数据读出后再把该字节写入到连接中,这样就实现了你发什么它也发什么的功能。
为了用于测试,在开启服务器后,打开cmd输入nc -nv 127.0.0.1 8080(需要下载netcat),然后就可以开始测试。
(3) auth(握手+认证)
客户端向代理服务器发送的协议报文如下:
其中第一个字段ver是协议版本号,socks5的值固定为5(0x05);第二个字段nmethods表示支持认证的数目,第三个字段methods表示对于每个支持的认证,都有一个字节表示其认证方法,00表示不需要认证,02表示需要密码认证。
而代理服务器返回的报文包括两个字段,一个ver一个method,即选中的认证方式,这里我们简单地选择00(不需要认证)。将process()修改如下:
func process(cilent net.Conn) {
defer cilent.Close()
reader := bufio.NewReader(cilent)
err := auth(reader, cilent)
if err != nil {
log.Printf("认证失败:%v", err)
return
}
log.Println("验证成功")
}
现在我们需要实现auth方法,用来认证:
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 %v", ver)
}
methodsz, err := reader.ReadByte()
if err != nil {
return fmt.Errorf("Read methodsz failed:%w", err)
}
method := make([]byte, methodsz)
_, err = io.ReadFull(reader, method) //剩下的全部读入
if err != nil {
return fmt.Errorf("Read methods failed:%w", err)
}
log.Println("ver:", ver, "method", method)
_, err = conn.Write([]byte{socks5Ver, 0x00}) //无需认证
if err != nil {
return fmt.Errorf("write error:%w", err)
}
return nil
}
首先读入一个字节,第一个字节是ver,如果不是0x05就错误了。再读入一个字节表示方法数,创建一个相同大小的字节数组,然后把剩余部分全部读入。
将请求读完后,发送认证报文即可(选择00表示无需认证)。
运行程序后在命令行输入:curl --socks5 127.0.0.1:8888 -v http://www.qq.com。
注意到客户端与代理的连接已经成功。后面代理与服务端失败是因为我们还没写。
(4) 请求阶段
接下来客户端向代理服务器发送请求,指出要通过代理访问的ip和端口。现在我们在process()函数后面再写一个connect函数来实现这一点。
即:
log.Println("验证成功")
err = connect(reader, cilent)
回忆请求阶段,浏览器会发送包含六个字段的包——ver(协议版本)、cmd(请求类型,0x01表示connect)、rsv(保留字段,值为0x00)、atype(目标地址——0x01表示ipv4,'0x03'表示域名)、addr对应atype、port(端口号)。
现在我们也需要依次读出数据并做处理。前四个字段一共四个字节,判断一下ver和cmd,然后根据atyp决定后面怎么读入。我们目前只支持host和ipv4,如果是ipv4,直接读入四个字节的地址;如果是host,先读入一个字节表示hostsize,然后在读入相应大小的地址。最后读入两个字节表示port,然后把端口转换为大端序。最后返回确认报文即可。
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 error:%w", err)
}
ver, cmd, atype := buf[0], buf[1], buf[3]
if ver != socks5Ver { //socks5协议
return fmt.Errorf("not supported %v", ver)
}
if cmd != cmdconnect { //不是连接请求.我们只处理连接
return fmt.Errorf("not supported %v", ver)
}
addr := ""
switch atype {
case ipv4:
_, err = io.ReadFull(reader, buf)
if err != nil {
return fmt.Errorf("read atype error:% w", err)
}
addr = fmt.Sprintf("%d.%d.%d.%d", buf[0], buf[1], buf[2], buf[3]) //写入地址.
case host:
hostsz, err := reader.ReadByte()
if err != nil {
return fmt.Errorf("read hostsz error:% w", err)
}
//make相同长度的buf来填充它.
host := make([]byte, hostsz)
_, err = io.ReadFull(reader, host)
if err != nil {
return fmt.Errorf("read host error:% w", err)
}
addr = string(host)
case ipv6:
return fmt.Errorf("Not support Ipv6.")
default:
return fmt.Errorf("Invalid type.")
}
_, err = io.ReadFull(reader, buf[:2])
//读取两个字节的端口,复用buf.
if err != nil {
return fmt.Errorf("read port error.")
}
port := binary.BigEndian.Uint16(buf[:2]) //大端序转换.
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)
}
return nil
}
我们现在再运行curl --socks5 127.0.0.1:8888 -v http://www.qq.com:
在代理服务器端输出了dial 221.198.70.47 80。这表示请求已经成功。
(5) relay阶段
现在代理服务器已经获得了目标的地址和端口,与之建立TCP连接即可。
port := binary.BigEndian.Uint16(buf[:2]) //大端序转换.
dest, err := net.Dial("tcp", fmt.Sprintf("%v:%v", addr, port)) //写成ip:端口与后端服务器发起TCP连接.
if err != nil {
return fmt.Errorf("dial failed error:%w", err)
}
defer dest.Close()
log.Println("dial", addr, port) //输出地址和端口
接下来我们创建两个子协程负责从浏览器->服务器(conn->dest),服务器->浏览器(dest->reader)传送信息。但是显然主协程会先运行完,我们可使用context进行阻塞。当数据交换完成时,即cancel函数被调用时,ctx.Done()执行,主协程才继续向下运行。
//返回确认报文:
_, 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
最后我们再来测试一下:
现在请求报文和返回报文均可看到。代理服务器已经正常运行啦!