Go语言工程实践案例 | 青训营

85 阅读2分钟

在前面我们学习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("猜大了!")
		}
	}
}

可正常运行:

QQ图片20230828172946.png

二、在线词典

(1) 抓包

进入fanyi.caiyunapp.com 后,右击后点击检查,找到network后点击dict,对其中Request urlpost的进行复制,然后打开这个网站 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),然后就可以开始测试。

QQ图片20230828194336.png

(3) auth(握手+认证)

客户端向代理服务器发送的协议报文如下:

QQ图片20230828194538.png

其中第一个字段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

QQ图片20230828201443.png

注意到客户端与代理的连接已经成功。后面代理与服务端失败是因为我们还没写。

(4) 请求阶段

接下来客户端向代理服务器发送请求,指出要通过代理访问的ip和端口。现在我们在process()函数后面再写一个connect函数来实现这一点。 即:

log.Println("验证成功")
	err = connect(reader, cilent)

回忆请求阶段,浏览器会发送包含六个字段的包——ver(协议版本)、cmd(请求类型,0x01表示connect)、rsv(保留字段,值为0x00)、atype(目标地址——0x01表示ipv4,'0x03'表示域名)、addr对应atypeport(端口号)。

现在我们也需要依次读出数据并做处理。前四个字段一共四个字节,判断一下vercmd,然后根据atyp决定后面怎么读入。我们目前只支持hostipv4,如果是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

QQ图片20230828204933.png

在代理服务器端输出了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

最后我们再来测试一下:

QQ图片20230828210227.png

现在请求报文和返回报文均可看到。代理服务器已经正常运行啦!