Go语言上手 - 实战案例 | 青训营

猜谜游戏(guessing-game)

项目概要

本次猜谜游戏是一个简单的命令行游戏,玩家需要猜测计算机生成的秘密数字,直到猜对为止。下面简要记录该游戏的功能和知识点:

功能介绍:

  1. 游戏生成一个在 1 到 100 范围内的随机整数作为随机数字。
  2. 提示玩家输入猜测的数字,并将输入解析成整数。
  3. 比较玩家的猜测和秘密数字,根据比较结果给出相应的提示,让玩家继续猜测。
  4. 当玩家猜对秘密数字时,打印胜利的提示,游戏结束。

知识点:

  1. 包管理和导入 :在 Go 语言中使用 import 来导入所需的包,比如 fmtmath/randos 等。
  2. 随机数生成 :使用 math/rand 包的 rand.Seed()rand.Intn() 函数来生成随机整数。
  3. 时间处理 :使用 time 包获取当前时间的纳秒级表示,用于初始化随机数生成器的种子值。
  4. 用户输入输出 :使用 bufio 包的 bufio.NewReader()ReadString() 函数来读取用户输入,并使用 fmt 包的 Println() 函数来打印输出。
  5. 字符串处理 :使用 strings 包的 Trim() 函数来处理输入中的结尾换行符。
  6. 类型转换 :使用 strconv 包的 Atoi() 函数将字符串转换成整数。
  7. 循环 :使用 for { ... } 来创建一个无限循环,使得游戏可以持续进行,直到玩家猜对为止。
  8. 条件语句 :使用 ifelse if 来根据玩家猜测和秘密数字的比较结果给出相应的提示。

通过这个简单的猜谜游戏,我们学习了如何在 Go 语言中实现基本的随机数生成、用户输入输出、字符串处理和循环等功能,这些是编程中常用的基础知识。同时,我们还了解了如何使用条件语句来实现游戏的逻辑判断。希望这个实战案例能够帮助我们更好地理解和学习 Go 语言的基础编程概念。

v1

代码如下:

go 复制代码
// 包声明,表示这是一个可执行程序的入口文件
package main

// 导包
//   > fmt 包:提供了格式化输入输出功能
//   > math/rand 包:用于生成随机数
import (
	"fmt"
	"math/rand"
)

// 程序的入口函数
func main() {
    // 声明了一个整数变量 maxNum,并将其初始化为 100,表示猜测的数字在 1 到 100 之间
	maxNum := 100
    
    // 声明了一个整数变量 secretNumber,创建了一个随机数字
	secretNumber := rand.Intn(maxNum)
    
    // 打印随机数字(方便查看随机数,实际游戏中不应该将这个数字展示给玩家)
	fmt.Println("The secret number is ", secretNumber)
}

运行结果如下:

bash 复制代码
$ go run guessing-game/v1/main.go 
The secret number is  50

rand.Intn(n)math/rand 包中的函数,用于生成一个大于等于 0 且小于 n 的随机整数。

v2

rand.Intn(n) 函数会在每次调用时生成一个不同的随机整数,其中这个整数是大于等于 0 且小于 n 的。它是根据伪随机数生成器(pseudo-random number generator)来实现的。

伪随机数生成器是根据一个初始种子值计算出一系列看似随机的数字,但实际上是根据某种算法推导出来的。因此,当种子值相同时,生成器产生的随机数序列也会相同。

在 Go 语言的 rand 包中,默认情况下,生成器的种子值是一个固定值(为了可复现性),这使得在每次运行程序时生成的随机数序列都是相同的。但这并不是在生产环境中推荐的做法,因为每次运行程序都会得到相同的随机数序列,这是不符合真正的随机性要求的。

为了产生不同的随机数序列,我们通常会通过修改种子值来确保每次运行程序时生成的随机数序列都不同。一种常用的方法是使用当前时间作为种子,以确保每次运行时种子值都不同,从而生成不同的随机数序列。

我们对第一个版本进行了改进,使用 time.Now().UnixNano() 作为种子来初始化随机数生成器。这样做的目的是确保每次运行程序时,都会生成不同的随机数序列,从而得到不同的随机数:

go 复制代码
package main

import (
	"fmt"
	"math/rand"
	"time"
)

func main() {
	maxNum := 100
	rand.Seed(time.Now().UnixNano())
	secretNumber := rand.Intn(maxNum)
	fmt.Println("The secret number is ", secretNumber)
}

v3

为了读取用户输入,程序改进后得到第三个版本:

go 复制代码
package main

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

func main() {
	maxNum := 100
	rand.Seed(time.Now().UnixNano())
	secretNumber := rand.Intn(maxNum)
	fmt.Println("The secret number is ", secretNumber)

	fmt.Println("Please input your guess")
	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, "\r\n")

	guess, err := strconv.Atoi(input)
	if err != nil {
		fmt.Println("Invalid input. Please enter an integer value")
		return
	}
	fmt.Println("You guess is", guess)
}

运行结果如下:

bash 复制代码
$ go run guessing-game/v3/main.go
The secret number is  5
Please input your guess
50
You guess is 50

在这个第三个版本的代码中,我们对前两个版本进行了进一步改进,实现了用户输入和输出的功能,并将输入解析成数字。

记录如下:

go 复制代码
import (
	"bufio"
	"fmt"
	"math/rand"
	"os"
	"strconv"
	"strings"
	"time"
)

我们新添加了一些包:

  • bufio 包提供了带缓冲的 I/O 操作,帮助我们更高效地处理输入输出
  • os 包提供了与操作系统交互的功能
  • strconv 包用于字符串和基本数据类型之间的转换
  • strings 包提供了处理字符串的功能。

一些代码的解读:

go 复制代码
reader := bufio.NewReader(os.Stdin)

在这行代码中,我们使用bufio.NewReader()函数创建了一个新的缓冲读取器,并将其赋值给变量reader。这样,reader就成为了一个可以从标准输入(键盘输入)读取数据的缓冲读取器。

  • bufiobufio是Go标准库中的一个包,提供了缓冲读写功能,可以帮助我们更高效地处理输入输出。
  • os.Stdinos.Stdin是Go标准库中的一个变量,表示标准输入流。它是程序默认的输入来源,通常是键盘输入。
  • bufio.NewReader():这是bufio包中的一个函数,用于创建一个新的缓冲读取器。它接收一个io.Reader类型的参数,并返回一个新的*bufio.Reader类型的实例。

通过这个reader变量,我们可以使用ReadString()ReadBytes()ReadLine()等方法来读取用户在终端输入的内容,并且由于使用了缓冲读取器,这样可以更高效地处理输入数据。

go 复制代码
input, err := reader.ReadString('\n')
if err != nil {
	fmt.Println("An error occurred while reading input. Please try again.", err)
	return
}
input = strings.Trim(input, "\r\n")

这部分代码用于读取玩家输入的内容,并将其存储在 input 变量中。reader.ReadString('\n') 会读取一行输入,直到遇到换行符为止。然后,我们使用 strings.Trim() 函数将输入中的结尾换行符 \r\n 去掉,以便后续处理。

  • input, err := reader.ReadString('\n')

这一行代码从一个名为reader的输入流中读取数据,直到遇到换行符('\n')为止。读取的内容被赋值给input变量,而err变量则用于捕获可能发生的错误。ReadString函数返回读取到的字符串以及可能的错误。

  • if err != nil { ... }

这是一个错误处理语句。它会检查ReadString函数执行过程中是否出现了错误。如果err变量不为nil,表示出现了错误,代码会进入花括号内的块。

  • input = strings.Trim(input, "\r\n")

这一行代码用于去除用户输入中的换行符(\r\n)。strings.Trim函数会将input字符串两侧的指定字符(\r\n)去除,返回一个去除了换行符的新字符串,该字符串会覆盖之前的input值。

go 复制代码
guess, err := strconv.Atoi(input)
if err != nil {
	fmt.Println("Invalid input. Please enter an integer value.")
	return
}
fmt.Println("Your guess is", guess)

这部分代码用于将输入的内容解析成数字。strconv.Atoi(input) 将字符串 input 转换成整数类型。如果转换失败(即用户输入的不是一个有效的整数),我们会打印错误信息并退出。否则,我们会将解析后的数字存储在 guess 变量中,并打印出玩家猜测的数字。

v4

接下来,继续添加游戏逻辑,比较玩家的猜测和秘密数字,并给出相应的提示。

第四个版本的代码如下:

go 复制代码
// ...(前面部分省略)

fmt.Println("You guess is", guess)

if guess > secretNumber {
	fmt.Println("Your guess is bigger than the secret number. Please try again")
} else if guess < secretNumber {
	fmt.Println("Your guess is smaller than the secret number. Please try again")
} else {
	fmt.Println("Correct, you Legend!")
}

这部分代码是游戏的逻辑判断。

程序运行如下:

shell 复制代码
$ go run guessing-game/v4/main.go 
The secret number is  64
Please input your guess
50
You guess is 50
Your guess is smaller than the secret number. Please try again

v5

在第五个版本中,我们通过添加一个循环,使得游戏可以持续进行,直到玩家猜对秘密数字才退出游戏。

完整程序如下:

go 复制代码
package main

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

func main() {
	maxNum := 100
	rand.Seed(time.Now().UnixNano())
	secretNumber := rand.Intn(maxNum)
	// fmt.Println("The secret number is ", secretNumber)

	fmt.Println("Please input your guess")
	reader := bufio.NewReader(os.Stdin)
	for {
		input, err := reader.ReadString('\n')
		if err != nil {
			fmt.Println("An error occured while reading input. Please try again", err)
			continue
		}
		input = strings.Trim(input, "\r\n")

		guess, err := strconv.Atoi(input)
		if err != nil {
			fmt.Println("Invalid input. Please enter an integer value")
			continue
		}
		fmt.Println("You guess is", guess)
		if guess > secretNumber {
			fmt.Println("Your guess is bigger than the secret number. Please try again")
		} else if guess < secretNumber {
			fmt.Println("Your guess is smaller than the secret number. Please try again")
		} else {
			fmt.Println("Correct, you Legend!")
			break
		}
	}
}

程序运行如下:

shell 复制代码
$ go run guessing-game/v5/main.go 
Please input your guess
50
You guess is 50
Your guess is bigger than the secret number. Please try again
25
You guess is 25
Your guess is smaller than the secret number. Please try again
35
You guess is 35
Your guess is bigger than the secret number. Please try again
30
You guess is 30
Your guess is smaller than the secret number. Please try again
33
You guess is 33
Your guess is smaller than the secret number. Please try again
34
You guess is 34
Correct, you Legend!

记录:

  • 第五个版本的代码使用了一个无限循环 for { ... },使得游戏可以持续进行,直到玩家猜对为止。
  • 玩家可以一直输入猜测,直到猜对为止。如果玩家猜错了,程序会继续提示玩家输入并比较,直到玩家猜对为止。
  • 当玩家猜对时,使用 break 关键字来退出循环,结束游戏。

在线词典(simpledict)

项目概要

本次在线词典案例是一个使用Go语言实现的命令行程序,用户可以在命令行中输入一个单词,然后通过调用第三方API查询该单词的翻译和解释,并将结果打印出来。

下面简要记录该词典的功能和知识点:

功能介绍:

  1. 支持在命令行中输入要查询的单词。
  2. 使用HTTP POST请求调用第三方API查询单词的翻译和解释。
  3. 使用结构体和JSON解析将API返回的复杂JSON数据反序列化为Go语言对象。
  4. 对API返回的状态码进行检查,处理错误情况。
  5. 打印查询结果,包括单词的UK和US发音以及单词的解释。

知识点:

  1. 使用Go标准库中的net/http包进行HTTP请求和响应的处理。
  2. 使用encoding/json包进行JSON数据的编码和解码。
  3. 结构体的定义和使用,以及结构体字段的标签用法。
  4. 使用指针对结构体进行传递,实现对结构体的修改。
  5. 错误处理和日志记录的基本方法。
  6. 使用命令行参数,通过os.Args获取命令行参数。
  7. 了解复杂JSON数据的处理方法,包括嵌套结构体和切片的使用。
  8. 使用第三方API进行数据查询,以及模拟浏览器的请求头信息。
  9. 代码的组织和模块化,将不同功能的代码分割为不同的函数。

以上是本次在线词典案例的功能和涵盖的主要知识点,通过实现这个案例,读者可以学习如何使用Go语言进行HTTP请求、JSON处理、命令行参数处理等常见编程任务,并且了解如何与第三方API进行交互,实现实际的功能性程序。

v1

版本一的代码如下:

go 复制代码
package main

import (
	"fmt"
	"io/ioutil"
	"log"
	"net/http"
	"strings"
)

func main() {
	client := &http.Client{}
	var data = strings.NewReader(`{"trans_type":"en2zh","source":"good"}`)
	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)
	}
	fmt.Printf("%s\n", bodyText)
}

下面让我们来逐步理解该程序的用意:

在导包操作过程,我们又认识了新的包:

go 复制代码
package main

import (
	"fmt"
	"io/ioutil"
	"log"
	"net/http"
	"strings"
)

其中:

  • io/ioutil用于读取HTTP响应体
  • log用于打印错误日志
  • net/http用于进行HTTP请求

(1)构建HTTP客户端和请求

首先是 构建HTTP客户端,向指定的 URL 发送一个 POST 请求

go 复制代码
client := &http.Client{}
var data = strings.NewReader(`{"trans_type":"en2zh","source":"good"}`)
req, err := http.NewRequest("POST", "https://api.interpreter.caiyunai.com/v1/dict", data)
if err != nil {
	log.Fatal(err)
}

这段代码创建了一个HTTP客户端client和一个HTTP POST请求req。请求的目标URL是"api.interpreter.caiyunai.com/v1/dict",并且...

进一步记录(啰里啰嗦版):

go 复制代码
client := &http.Client{}

创建了一个名为 client 的变量,它是一个指向 http.Client 结构体的指针。http.Client 是 Go 标准库中用于发送 HTTP 请求的类型。默认情况下,http.Client 具有合理的超时时间和重试策略。

go 复制代码
var data = strings.NewReader(`{"trans_type":"en2zh","source":"good"}`)

定义了一个名为 data 的变量,它是一个 io.Reader 接口类型。strings.NewReader() 函数用于将给定的字符串转换为 io.Reader,这样我们可以将其作为请求体发送到服务器。其中,我们发送的数据是一个包含了 trans_typesource 字段的 JSON 对象。

go 复制代码
req, err := http.NewRequest("POST", "https://api.interpreter.caiyunai.com/v1/dict", data)
if err != nil {
    log.Fatal(err)
}

在这里,我们使用 http.NewRequest() 函数创建一个新的 HTTP 请求。该函数接受三个参数:

  • 第一个参数是 HTTP 方法,这里是 "POST",表示我们将发送一个 POST 请求。
  • 第二个参数是请求的目标 URL,这里是 "api.interpreter.caiyunai.com/v1/dict",一个... API 端点。
  • 第三个参数是请求体的数据,也就是我们之前准备好的 data,它是一个包含 JSON 数据的 io.Reader

(2)设置请求头

go 复制代码
req.Header.Set("Connection", "keep-alive")
req.Header.Set("DNT", "1")
// ... (省略了其他的请求头设置,类似地设置了很多请求头)

这段代码设置了HTTP请求头,模拟了请求来自于浏览器的行为,包括一些常见的浏览器头信息,比如User-AgentRefererCookie等,以及一些其他的特定头信息。

进一步记录

  1. 请求头(HTTP Request Headers):HTTP 请求头是在客户端向服务器发送请求时,附加在请求中的一组键值对信息。它们包含了关于请求的附加信息,如用户代理、授权信息、内容类型等。
  2. 设置请求头的作用:设置请求头的目的是向服务器传递额外的信息,告知服务器有关请求的更多细节。这些信息可能用于区分不同类型的请求,验证用户身份,指定客户端支持的数据格式,处理跨域请求等。
  3. 对应函数的请求头设置:
请求头字段 说明 示例值
Connection 控制是否保持连接 "keep-alive"
DNT Do Not Track 标志,用于表明用户不想被跟踪 "1"
os-version 操作系统版本 ""
sec-ch-ua-mobile 请求端设备的移动性标志 "?0"
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"
app-name 应用程序名称 "xy"
Content-Type 请求体的媒体类型 "application/json;charset=UTF-8"
Accept 客户端可接受的响应内容类型 "application/json, text/plain, */*"
device-id 设备ID ""
os-type 操作系统类型 "web"
X-Authorization 自定义授权头部字段,可能用于传递令牌等 "token:qgemv4jr1y38jyq6vhvi"
Origin 表示请求的来源 "fanyi.caiyunapp.com"
Sec-Fetch-Site 请求的目标资源所属的类型,用于跨站请求伪造(CSRF)防御 "cross-site"
Sec-Fetch-Mode 请求的模式,比如 cors,no-cors 等 "cors"
Sec-Fetch-Dest 请求的目标资源类型 "empty"
Referer 表示请求的来源页 "fanyi.caiyunapp.com/"
Accept-Language 客户端可接受的语言 "zh-CN,zh;q=0.9"
Cookie 客户端发送的 Cookie "_ym_uid=16456948721020430059; _ym_d=1645694872"

(3)发送HTTP请求并获取响应

go 复制代码
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客户端发送构建的请求req,并获取到服务器返回的响应resp。然后使用ioutil.ReadAll读取响应体中的数据,并将其存储在bodyText中。最后,通过defer resp.Body.Close()语句确保在函数返回前关闭响应体,以防止资源泄漏。

进一步记录:

go 复制代码
// 发起 HTTP 请求,并获取响应
resp, err := client.Do(req)
if err != nil {
    log.Fatal(err)
}
defer resp.Body.Close()

使用 client.Do(req) 来发送 HTTP 请求,并得到对应的响应 resp 和可能的错误 err

client 是一个执行 HTTP 请求的客户端对象,req 则是前面设置好的请求对象,它包含了请求的 URL、方法、请求头和请求体等信息。

如果在发送请求的过程中出现错误,例如网络连接问题或者服务器无法响应,会将错误信息打印并终止程序运行。log.Fatal(err) 会打印错误信息并调用 os.Exit(1) 终止程序。

go 复制代码
// 关闭响应体
defer resp.Body.Close()

通过 resp.Body.Close() 关闭响应体。在 Go 语言中,关闭响应体是很重要的,因为它会确保与服务器之间的网络连接得到及时释放,防止资源泄露。

go 复制代码
// 读取响应体的内容
bodyText, err := ioutil.ReadAll(resp.Body)
if err != nil {
    log.Fatal(err)
}

使用 ioutil.ReadAll(resp.Body) 来读取响应体的内容,并将内容存储在 bodyText 变量中。ioutil.ReadAll 函数会一次性读取整个响应体,适用于响应体较小的情况。

最后,检查读取响应体的过程中是否出现了错误,如果有错误,同样会打印错误信息并终止程序运行。

(4)打印查询结果

go 复制代码
fmt.Printf("%s\n", bodyText)

在这个版本中,bodyText是原始的JSON响应,包含了查询单词"good"的翻译结果。

v2

第二个版本代码如下:

go 复制代码
package main

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

type DictRequest struct {
	TransType string `json:"trans_type"`
	Source    string `json:"source"`
	UserID    string `json:"user_id"`
}

func main() {
	client := &http.Client{}
	request := DictRequest{TransType: "en2zh", Source: "good"}
	buf, err := json.Marshal(request)
	if err != nil {
		log.Fatal(err)
	}
	var data = bytes.NewReader(buf)
	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)
	}
	fmt.Printf("%s\n", bodyText)
}

在这个第二个版本的代码中,主要做了以下改进:

(1)定义了结构体 DictRequest

go 复制代码
type DictRequest struct {
	TransType string `json:"trans_type"`
	Source    string `json:"source"`
	UserID    string `json:"user_id"`
}

这个结构体用于构造要发送给API的请求数据。它有三个字段:

  • TransType表示翻译类型
  • Source表示要查询的单词
  • UserID是可选的用户ID字段(在这个版本中未被使用)。

这个结构体用于在程序中表示一种数据结构,而通过 json 标签,该结构体可以方便地与 JSON 数据进行转换和交互。

(2)使用 json.Marshal 来生成 JSON 数据

在这里,我们创建了一个名为 request 的变量,并用 DictRequest 结构体初始化它。

go 复制代码
request := DictRequest{TransType: "en2zh", Source: "good"}

我们使用了结构体字面量(Struct Literal)的形式来初始化结构体变量:

  • 指定了字段名和对应的值,即 TransType: "en2zh"Source: "good"
  • 同时忽略了 UserID 字段,没有为它指定值。

这将创建一个 DictRequest 结构体的实例,其中 TransType 字段的值是 "en2zh"Source 字段的值是 "good",而 UserID 字段的值默认为空字符串(因为字符串类型的默认值是空字符串)。

接着,通过json.Marshal函数将request对象转换为JSON格式的字节数组buf

go 复制代码
buf, err := json.Marshal(request)
if err != nil {
	log.Fatal(err)
}

使用 Go 标准库中的 json.Marshal() 函数将 request 结构体变量序列化为 JSON 数据。json.Marshal() 函数接收一个普通的 Go 值(例如结构体、切片、映射等),并返回一个字节切片([]byte)表示 JSON 编码后的数据。这里将序列化后的结果存储在 buf 变量中。

最后检查进行 JSON 序列化时是否发生了错误。

json.Marshal() 函数在处理正常情况下不会返回错误,但如果传递给它的值无法被正确地转换为 JSON 格式,例如包含了不支持的数据类型,那么它会返回一个非空的错误值。在这里,通过检查 err 变量是否为非空,我们可以捕获可能发生的错误,并使用 log.Fatal() 函数将错误信息输出到控制台并终止程序的执行。

(3)使用 bytes.NewReader 构造请求体:

下面创建了一个名为 data 的变量,并将 buf 字节切片转换为 bytes.Reader 类型的值赋给它。data 用于作为HTTP请求的请求体数据。

go 复制代码
var data = bytes.NewReader(buf)

bytes.Reader 是 Go 标准库中的一种类型,它允许我们从一个字节切片中读取数据,并提供了一些方法来进行读取操作。

在这里,buf 是之前通过 json.Marshal() 函数将结构体序列化为 JSON 格式后得到的字节切片,bytes.NewReader(buf) 将这个字节切片转换为 bytes.Reader 对象。

当我们将刚创建的bytes.Reader 对象赋值给名为 data 的变量后,该变量就持有了一个可以从中读取 JSON 数据的 bytes.Reader 对象。

一般这种转换是用于将字节切片转换为实现了 io.Reader 接口的类型。在这个例子中,bytes.Reader 实现了 io.Reader 接口,因此可以方便地用于从字节切片中读取数据,例如传递给一些需要 io.Reader 参数的函数或方法,或者用于进一步的数据处理操作。

总的来说,第二个版本的代码相较于第一个版本,主要改进了构造JSON数据的方法,使用了结构体和json.Marshal来实现更优雅的JSON生成方式。代码也更加清晰,易于维护。

v3

在第三个版本的代码中,主要改进了对API响应结果的处理,使用了结构体DictResponse来解析和存储返回的JSON数据。

代码如下:

go 复制代码
package main

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

type DictRequest struct {
	TransType string `json:"trans_type"`
	Source    string `json:"source"`
	UserID    string `json:"user_id"`
}

type DictResponse struct {
	Rc   int `json:"rc"`
	Wiki struct {
		KnownInLaguages int `json:"known_in_laguages"`
		Description     struct {
			Source string      `json:"source"`
			Target interface{} `json:"target"`
		} `json:"description"`
		ID   string `json:"id"`
		Item struct {
			Source string `json:"source"`
			Target string `json:"target"`
		} `json:"item"`
		ImageURL  string `json:"image_url"`
		IsSubject string `json:"is_subject"`
		Sitelink  string `json:"sitelink"`
	} `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"`
}

func main() {
	client := &http.Client{}
	request := DictRequest{TransType: "en2zh", Source: "good"}
	buf, err := json.Marshal(request)
	if err != nil {
		log.Fatal(err)
	}
	var data = bytes.NewReader(buf)
	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)
	}
	var dictResponse DictResponse
	err = json.Unmarshal(bodyText, &dictResponse)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("%#v\n", dictResponse)
}

在这个第三个版本的代码中,主要做了以下改进:

(1)定义了更加复杂的结构体

go 复制代码
type DictResponse struct {
	// 省略了一部分字段,只展示了一些复杂嵌套结构的字段
	Rc   int `json:"rc"`
	Wiki struct {
		// ...
	} `json:"wiki"`
	Dictionary struct {
		// ...
	} `json:"dictionary"`
}

这个结构体用于解析API返回的复杂JSON数据。它包含了嵌套的字段,其中WikiDictionary字段本身又是两个嵌套的结构体。

(2)解析 API 响应

go 复制代码
var dictResponse DictResponse
err = json.Unmarshal(bodyText, &dictResponse)
if err != nil {
	log.Fatal(err)
}

这里使用json.Unmarshal函数将API返回的JSON数据bodyText解析为DictResponse结构体,并存储在dictResponse变量中。通过传递&dictResponse的指针,Unmarshal函数可以直接对dictResponse进行修改,将解析后的数据存储在其中。

进一步记录:

go 复制代码
var dictResponse DictResponse

声明了一个名为 dictResponse 的变量,它是一个 DictResponse 结构体类型的实例。这个变量将用于存储解析后的 JSON 数据。

go 复制代码
err = json.Unmarshal(bodyText, &dictResponse)

使用 Go 标准库中的 json.Unmarshal() 函数将 bodyText 字节切片解析为 DictResponse 结构体。

json.Unmarshal() 函数接收两个参数:

  • 参数一:包含 JSON 数据的字节切片 bodyText
  • 参数二:指向目标结构体的指针 &dictResponse

& 符号用于获取 dictResponse 变量的地址,因为 json.Unmarshal() 函数需要一个指向目标变量的指针来解析 JSON 数据并填充结构体。

解析过程中,json.Unmarshal() 会根据 DictResponse 结构体的字段标签(json 标签)来匹配 JSON 数据的字段,然后将对应的值赋给 dictResponse 结构体的字段。

go 复制代码
if err != nil { 
    log.Fatal(err)
}

检查在进行 JSON 解析时是否发生了错误。

总的来说,这个第三个版本的代码相较于前两个版本,主要改进了对API响应结果的处理方式,使用了结构体来解析和存储复杂的JSON数据。这种方式更加灵活、可读性更强。此外,通过使用JSON生成工具可以简化结构体的定义过程,提高代码编写的效率。现在这个版本的代码已经接近最终版本。

v4

下面是最终版本的代码:

go 复制代码
package main

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

type DictRequest struct {
	TransType string `json:"trans_type"`
	Source    string `json:"source"`
	UserID    string `json:"user_id"`
}

type DictResponse struct {
	Rc   int `json:"rc"`
	Wiki struct {
		KnownInLaguages int `json:"known_in_laguages"`
		Description     struct {
			Source string      `json:"source"`
			Target interface{} `json:"target"`
		} `json:"description"`
		ID   string `json:"id"`
		Item struct {
			Source string `json:"source"`
			Target string `json:"target"`
		} `json:"item"`
		ImageURL  string `json:"image_url"`
		IsSubject string `json:"is_subject"`
		Sitelink  string `json:"sitelink"`
	} `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"`
}

func query(word string) {
	client := &http.Client{}
	request := DictRequest{TransType: "en2zh", Source: word}
	buf, err := json.Marshal(request)
	if err != nil {
		log.Fatal(err)
	}
	var data = bytes.NewReader(buf)
	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)
	}
	if resp.StatusCode != 200 {
		log.Fatal("bad StatusCode:", resp.StatusCode, "body", string(bodyText))
	}
	var dictResponse DictResponse
	err = json.Unmarshal(bodyText, &dictResponse)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(word, "UK:", dictResponse.Dictionary.Prons.En, "US:", dictResponse.Dictionary.Prons.EnUs)
	for _, item := range dictResponse.Dictionary.Explanations {
		fmt.Println(item)
	}
}

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)
}

运行结果如下:

shell 复制代码
$ go run simpledict/v4/main.go spontaneous
spontaneous UK: [spɔnˈteiniəs] US: [spɑnˈtenɪəs]
a.自然的;自发的;自然生长的

第四个版本是最终版代码,它在第三个版本的基础上进行了进一步优化,并添加了命令行参数的支持,使得用户可以在命令行中输入要查询的单词。

下面开始记录:

(1)修改 main 函数

编写简单的main函数,用于接收用户输入的单词,并调用query函数进行查询。

go 复制代码
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)
}

main函数中,首先通过os.Args获取命令行参数,并判断参数的数量是否为2(包括程序名本身和要查询的单词)。如果参数数量不等于2,则输出简单的使用提示信息,并退出程序。否则将用户输入的要查询的单词存储在word变量中,然后调用query函数进行查询。

(2)新增 query 函数

将代码主体改成一个query函数,用于查询单词的释义,并通过参数传递要查询的单词。

go 复制代码
func query(word string) {
	// ... (query函数的内容和之前的代码一样,保持不变)
}

这个函数用于进行单词查询,其内容和之前版本的代码一样,发送HTTP请求并解析API响应,并将查询结果打印出来。

(3)优化打印查询结果

使用for range循环迭代dictResponse.Dictionary.Explanations,打印出查询结果中的释义部分。

go 复制代码
fmt.Println(word, "UK:", dictResponse.Dictionary.Prons.En, "US:", dictResponse.Dictionary.Prons.EnUs)
for _, item := range dictResponse.Dictionary.Explanations {
	fmt.Println(item)
}

在这个版本中,对打印查询结果进行了优化,输出了查询单词的UK和US发音,并逐行打印了单词的解释。

(4)错误处理

query函数中增加了对API返回状态码的检查,如果不是 200 ,会打印错误信息并退出程序。

go 复制代码
if resp.StatusCode != 200 {
	log.Fatal("bad StatusCode:", resp.StatusCode, "body", string(bodyText))
}

这个版本中添加了对API响应状态码的检查。如果返回的状态码不是 200,说明请求出现了问题,打印错误信息并退出程序。

总的来说,这个最终版本的代码相较于前几个版本,主要增加了命令行参数支持,并进行了更好的打印查询结果的优化。用户现在可以在命令行中输入要查询的单词,并得到相应的查询结果。代码中使用了query函数封装了查询逻辑,使得程序的结构更加清晰和易于扩展。通过这个版本,我们已经完成了一个基本的命令行词典工具,可以在命令行中方便地进行单词查询。

SOCKS5 代理 - proxy

项目概要

本次简单的Socks5代理服务器项目实现了一个基本的Socks5代理服务器,能够处理客户端的连接请求,并将数据在代理服务器和目标服务器之间进行转发。

它实现了以下功能:

  1. 客户端认证:客户端连接后,通过Socks5协议的认证阶段,服务器回复客户端支持的认证方法,此实现中默认不需要认证(No Authentication Required)。
  2. 连接请求处理:客户端发送代理请求,服务器解析请求中的目标地址和端口,并建立与目标服务器的连接。
  3. 数据转发:服务器与目标服务器建立连接后,将客户端的数据转发给目标服务器,并将目标服务器的响应数据转发给客户端。

知识点

  1. 网络编程:使用Go语言的net包来处理网络连接、监听和数据传输。
  2. Socks5协议:了解Socks5协议的基本规范,包括认证阶段和连接请求阶段的数据格式和交互流程。
  3. 并发编程:利用Go语言的Goroutine来实现并发处理多个客户端连接,实现高并发的代理服务器。
  4. IO处理:使用Go语言的bufioio包来进行数据读写操作。
  5. Context处理:使用Go语言的context包来实现并发任务的控制和取消。

引入

什么是代理

当你在计算机网络中访问网站或进行网络通信时,代理(Proxy)是一个位于你和目标服务器之间的中间服务器。代理服务器充当客户端和目标服务器之间的中间人,代替客户端向服务器发送请求,并将服务器的响应转发回客户端。这个中间服务器的存在就是代理。

代理是一个位于客户端和目标服务器之间的中间服务器,它代替客户端与目标服务器进行通信。代理服务器作为中间人存在的主要目的是为了提供更多的灵活性、隐私和安全。它允许用户在访问互联网时隐藏自己的真实IP地址,绕过地理限制,访问被封锁的网站,同时提供一定程度的安全性和隐私保护。代理还可以用于负载均衡和加速访问,从而提高网络性能和用户体验。

SOCKS5 代理

SOCKS5(Socket Secure 5)是一个网络协议,它在传输层上工作,是代理服务器的一种类型。

相比于HTTP代理,SOCKS代理更加灵活,可以代理各种类型的网络流量,包括 TCP 和 UDP 协议。

SOCKS5代理的主要特点是它可以在客户端和代理服务器之间建立一个 TCP 或 UDP 连接,并直接将数据转发给目标服务器,而不需要解析或修改数据包。这种特性使得SOCKS5代理适用于很多不同类型的网络应用,如 P2P 文件共享、实时视频传输等。

实战项目

最后一个实战案例是写一个 socks5 代理服务器。

"这个协议历史比较久远,诞生于互联网早期。它的用途是,比如某些企业的内网为了确保安全性,有很严格的防火墙策略,但是带来的副作用就是访问某些资源会很麻烦。"

"socks5 相当于在防火墙开了个口子,让授权的用户可以通过单个端口去访问内部的所有资源。实际上很多翻墙软件,最终暴露的也是个 socks5 协议的端口"

虽然很多人将代理服务器与翻墙联系在一起,但代理服务器的用途远不止于此。代理服务器作为一种中间服务器,可以代表客户端向其他服务器发送请求。在网络爬虫中,使用代理服务器可以解决IP访问频率限制问题,因为它们可以隐藏真实的客户端IP地址并提供新的IP地址来进行请求,从而减轻对单个IP的访问频率限制。这样,爬虫就可以通过多个代理IP来轮流发送请求,避免被目标服务器封禁。

socks5协议正是一种常用的代理协议之一。它的优点是支持UDP协议以及认证机制,能够提供更加安全和灵活的代理服务。因此,很多代理IP池中收录的代理IP都是基于socks5协议的,因为它在很多场景下都表现得非常可靠。

我们来看一下最终写完的代理服务器的效果。

curl是一个命令行工具,用于在操作系统上进行数据传输,它支持各种协议,用于传输和接收数据,例如文件下载、API请求等。curl的名字源自"Client URL",意味着它是一个用于客户端URL操作的工具。

工作原理(流程)

SOCKS5 协议是一种代理协议,它允许客户端(如浏览器)通过 SOCKS5 代理服务器来转发其网络流量。这样的设计使得代理服务器能够隐藏客户端的真实IP地址,并将请求转发给真正的服务器,从而实现代理功能。

以下是 SOCKS5 协议的工作流程:

  1. 握手阶段
    • 客户端(浏览器)和 SOCKS5 代理服务器建立 TCP 连接。
    • 客户端发送一个握手请求,其中包括 SOCKS5 协议的版本号和支持的认证方式。
    • 代理服务器从支持的认证方式中选择一种,返回给客户端。
  2. 认证阶段 (可选):
    • 如果代理服务器返回的认证方式不为0(表示无需认证),则客户端需要进行相应的认证过程,这一步是可选的,可以没有认证阶段。
  3. 请求阶段
    • 客户端发送真正的请求给 SOCKS5 代理服务器,请求建立连接到目标服务器的主机和端口。
    • 代理服务器收到请求后,会解析目标主机和端口,并尝试与目标服务器建立连接。
  4. Relay 阶段
    • 如果连接到目标服务器成功,代理服务器将客户端的请求转发给目标服务器。
    • 代理服务器与目标服务器之间建立的连接被称为"Relay(中继)连接",它负责转发客户端和目标服务器之间的数据。
    • 一旦客户端和目标服务器之间的连接建立,代理服务器将数据从客户端发送到目标服务器,并将目标服务器的响应数据转发回客户端。

在这个过程中,SOCKS5 协议允许代理服务器透明地转发数据,不关心具体的应用层协议,这使得 SOCKS5 协议非常通用和灵活。代理服务器只需要遵循 SOCKS5 协议的规范,并将请求和响应进行适当的转发。

v1

代码如下:

go 复制代码
package main

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

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)
	}
}

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
		}
	}
}

这是Socks5代理服务器项目的第一个版本代码,它是一个简单的回显代理服务器。

记录如下:

go 复制代码
server, err := net.Listen("tcp", "127.0.0.1:1080")

上述代码创建了一个TCP网络监听器,监听在本地地址 127.0.0.1 的 1080 端口。这将作为我们的Socks5代理服务器的监听地址。

接着一个 for 循环,不断接收客户端连接。

go 复制代码
client, err := server.Accept()

当有客户端尝试连接时,Accept方法会返回一个新的连接client

go 复制代码
go process(client)

如果接受连接时出现错误,服务器会记录错误信息,然后继续等待下一个客户端连接。

否则为每个客户端连接启动一个独立的process协程来处理客户端请求,这样可以并发处理多个客户端连接。

go 复制代码
// 处理客户端连接的函数
func process(conn net.Conn) {
    // 在函数结束前关闭连接,确保连接资源得到释放。
	defer conn.Close()
    // 使用bufio包创建一个带缓冲的读取器,用于从连接中读取数据
	reader := bufio.NewReader(conn)
    // 无限循环,不断从客户端连接读取数据并回显
	for {
        // 从客户端连接中读取一个字节的数据。
		b, err := reader.ReadByte()
        // 如果读取数据时出现错误,跳出循环,关闭连接,结束处理函数
		if err != nil {
			break
		}
        // 将读取的字节回显给客户端,直接写回到连接中
		_, err = conn.Write([]byte{b})
        // 如果回写数据时出现错误,跳出循环,关闭连接,结束处理函数
		if err != nil {
			break
		}
	}
}

v2

第二个版本的代码如下:

go 复制代码
package main

import (
	"bufio"
	"fmt"
	"io"
	"log"
	"net"
)

const socks5Ver = 0x05
const cmdBind = 0x01
const atypeIPV4 = 0x01
const atypeHOST = 0x03
const atypeIPV6 = 0x04

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)
	}
}

func process(conn net.Conn) {
	defer conn.Close()
	reader := bufio.NewReader(conn)
	err := auth(reader, conn)
	if err != nil {
		log.Printf("client %v auth failed:%v", conn.RemoteAddr(), err)
		return
	}
	log.Println("auth success")
}

func auth(reader *bufio.Reader, conn net.Conn) (err error) {
	// +----+----------+----------+
	// |VER | NMETHODS | METHODS  |
	// +----+----------+----------+
	// | 1  |    1     | 1 to 255 |
	// +----+----------+----------+
	// VER: 协议版本,socks5为0x05
	// NMETHODS: 支持认证的方法数量
	// METHODS: 对应NMETHODS,NMETHODS的值为多少,METHODS就有多少个字节。RFC预定义了一些值的含义,内容如下:
	// X'00' NO AUTHENTICATION REQUIRED
	// X'02' USERNAME/PASSWORD

	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)
	// +----+--------+
	// |VER | METHOD |
	// +----+--------+
	// | 1  |   1    |
	// +----+--------+
	_, err = conn.Write([]byte{socks5Ver, 0x00})
	if err != nil {
		return fmt.Errorf("write failed:%w", err)
	}
	return nil
}

第二个版本在第一个版本的基础上增加了对Socks5代理的认证部分。

下面为一些代码进行记录:

go 复制代码
const socks5Ver = 0x05
const cmdBind = 0x01
const atypeIPV4 = 0x01
const atypeHOST = 0x03
const atypeIPV6 = 0x04

一些常量定义,用于表示Socks5协议中的一些数值,例如协议版本、命令类型和地址类型等。

go 复制代码
func process(conn net.Conn) {
	defer conn.Close()
	reader := bufio.NewReader(conn)
	err := auth(reader, conn)
	if err != nil {
		log.Printf("client %v auth failed: %v", conn.RemoteAddr(), err)
		return
	}
	log.Println("auth success")
}

process 函数负责处理客户端连接。首先进行了认证(调用 auth 函数),如果认证失败,则记录日志并关闭连接。如果认证成功,则打印认证成功的消息。

go 复制代码
func auth(reader *bufio.Reader, conn net.Conn) (err error) {
	// 忽略...
}

auth 函数用于处理Socks5协议的认证过程。根据Socks5协议规定的流程,客户端在连接服务器后,首先需要发送一个认证请求的消息,包含了支持的认证方法列表。服务器收到该消息后,从中选择一种支持的认证方法进行回复。

  • 首先,读取并解析客户端发送的协议版本 ver
  • 然后,检查协议版本是否为Socks5(socks5Ver),如果不是则返回一个错误。
  • 接着,读取客户端发送的支持的认证方法数量 methodSize
  • 读取 methodSize 个字节,表示支持的认证方法列表 method
  • 打印协议版本和认证方法列表。
  • 回复客户端的认证响应消息,此处回复为 0x05, 0x00 表示不需要认证。

v3

下面是第三个版本的代码,在前两个版本的基础上增加了处理Socks5代理连接请求的部分:

go 复制代码
package main

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

const socks5Ver = 0x05
const cmdBind = 0x01
const atypeIPV4 = 0x01
const atypeHOST = 0x03
const atypeIPV6 = 0x04

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)
	}
}

func process(conn net.Conn) {
	defer conn.Close()
	reader := bufio.NewReader(conn)
	err := auth(reader, conn)
	if err != nil {
		log.Printf("client %v auth failed:%v", conn.RemoteAddr(), err)
		return
	}
	err = connect(reader, conn)
	if err != nil {
		log.Printf("client %v auth failed:%v", conn.RemoteAddr(), err)
		return
	}
}

func auth(reader *bufio.Reader, conn net.Conn) (err error) {
	// +----+----------+----------+
	// |VER | NMETHODS | METHODS  |
	// +----+----------+----------+
	// | 1  |    1     | 1 to 255 |
	// +----+----------+----------+
	// VER: 协议版本,socks5为0x05
	// NMETHODS: 支持认证的方法数量
	// METHODS: 对应NMETHODS,NMETHODS的值为多少,METHODS就有多少个字节。RFC预定义了一些值的含义,内容如下:
	// X'00' NO AUTHENTICATION REQUIRED
	// X'02' USERNAME/PASSWORD

	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)
	}

	// +----+--------+
	// |VER | METHOD |
	// +----+--------+
	// | 1  |   1    |
	// +----+--------+
	_, err = conn.Write([]byte{socks5Ver, 0x00})
	if err != nil {
		return fmt.Errorf("write failed:%w", err)
	}
	return nil
}

func connect(reader *bufio.Reader, conn net.Conn) (err error) {
	// +----+-----+-------+------+----------+----------+
	// |VER | CMD |  RSV  | ATYP | DST.ADDR | DST.PORT |
	// +----+-----+-------+------+----------+----------+
	// | 1  |  1  | X'00' |  1   | Variable |    2     |
	// +----+-----+-------+------+----------+----------+
	// VER 版本号,socks5的值为0x05
	// CMD 0x01表示CONNECT请求
	// RSV 保留字段,值为0x00
	// ATYP 目标地址类型,DST.ADDR的数据对应这个字段的类型。
	//   0x01表示IPv4地址,DST.ADDR为4个字节
	//   0x03表示域名,DST.ADDR是一个可变长度的域名
	// DST.ADDR 一个可变长度的值
	// DST.PORT 目标端口,固定2个字节

	buf := make([]byte, 4)
	_, err = io.ReadFull(reader, buf)
	if err != nil {
		return fmt.Errorf("read header failed:%w", err)
	}
	ver, cmd, atyp := buf[0], buf[1], buf[3]
	if ver != socks5Ver {
		return fmt.Errorf("not supported ver:%v", ver)
	}
	if cmd != cmdBind {
		return fmt.Errorf("not supported cmd:%v", cmd)
	}
	addr := ""
	switch atyp {
	case atypeIPV4:
		_, err = io.ReadFull(reader, buf)
		if err != nil {
			return fmt.Errorf("read atyp failed:%w", err)
		}
		addr = fmt.Sprintf("%d.%d.%d.%d", buf[0], buf[1], buf[2], buf[3])
	case atypeHOST:
		hostSize, err := reader.ReadByte()
		if err != nil {
			return fmt.Errorf("read hostSize failed:%w", err)
		}
		host := make([]byte, hostSize)
		_, err = io.ReadFull(reader, host)
		if err != nil {
			return fmt.Errorf("read host failed:%w", err)
		}
		addr = string(host)
	case atypeIPV6:
		return errors.New("IPv6: no supported yet")
	default:
		return errors.New("invalid atyp")
	}
	_, err = io.ReadFull(reader, buf[:2])
	if err != nil {
		return fmt.Errorf("read port failed:%w", err)
	}
	port := binary.BigEndian.Uint16(buf[:2])

	log.Println("dial", addr, port)

	// +----+-----+-------+------+----------+----------+
	// |VER | REP |  RSV  | ATYP | BND.ADDR | BND.PORT |
	// +----+-----+-------+------+----------+----------+
	// | 1  |  1  | X'00' |  1   | Variable |    2     |
	// +----+-----+-------+------+----------+----------+
	// VER socks版本,这里为0x05
	// REP Relay field,内容取值如下 X'00' succeeded
	// RSV 保留字段
	// ATYPE 地址类型
	// BND.ADDR 服务绑定的地址
	// BND.PORT 服务绑定的端口DST.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
}

记录如下:

process 函数中,增加了对连接请求的处理。在客户端完成认证后,调用 connect 函数处理连接请求。

go 复制代码
func connect(reader *bufio.Reader, conn net.Conn) (err error) {
	// ... 省略了之前的代码,用于解析连接请求的信息 ...

	_, 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
}

connect 函数用于处理Socks5代理的连接请求。在此版本中,我们只是简单地回复客户端,表示连接请求成功。该回复遵循Socks5协议中的响应格式。

v4

下面是第四个版本(最终版)的代码,已经实现了一个简单的Socks5代理服务器。

代码如下:

go 复制代码
package main

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

const socks5Ver = 0x05
const cmdBind = 0x01
const atypeIPV4 = 0x01
const atypeHOST = 0x03
const atypeIPV6 = 0x04

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)
	}
}

func process(conn net.Conn) {
	defer conn.Close()
	reader := bufio.NewReader(conn)
	err := auth(reader, conn)
	if err != nil {
		log.Printf("client %v auth failed:%v", conn.RemoteAddr(), err)
		return
	}
	err = connect(reader, conn)
	if err != nil {
		log.Printf("client %v auth failed:%v", conn.RemoteAddr(), err)
		return
	}
}

func auth(reader *bufio.Reader, conn net.Conn) (err error) {
	// +----+----------+----------+
	// |VER | NMETHODS | METHODS  |
	// +----+----------+----------+
	// | 1  |    1     | 1 to 255 |
	// +----+----------+----------+
	// VER: 协议版本,socks5为0x05
	// NMETHODS: 支持认证的方法数量
	// METHODS: 对应NMETHODS,NMETHODS的值为多少,METHODS就有多少个字节。RFC预定义了一些值的含义,内容如下:
	// X'00' NO AUTHENTICATION REQUIRED
	// X'02' USERNAME/PASSWORD

	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)
	}

	// +----+--------+
	// |VER | METHOD |
	// +----+--------+
	// | 1  |   1    |
	// +----+--------+
	_, err = conn.Write([]byte{socks5Ver, 0x00})
	if err != nil {
		return fmt.Errorf("write failed:%w", err)
	}
	return nil
}

func connect(reader *bufio.Reader, conn net.Conn) (err error) {
	// +----+-----+-------+------+----------+----------+
	// |VER | CMD |  RSV  | ATYP | DST.ADDR | DST.PORT |
	// +----+-----+-------+------+----------+----------+
	// | 1  |  1  | X'00' |  1   | Variable |    2     |
	// +----+-----+-------+------+----------+----------+
	// VER 版本号,socks5的值为0x05
	// CMD 0x01表示CONNECT请求
	// RSV 保留字段,值为0x00
	// ATYP 目标地址类型,DST.ADDR的数据对应这个字段的类型。
	//   0x01表示IPv4地址,DST.ADDR为4个字节
	//   0x03表示域名,DST.ADDR是一个可变长度的域名
	// DST.ADDR 一个可变长度的值
	// DST.PORT 目标端口,固定2个字节

	buf := make([]byte, 4)
	_, err = io.ReadFull(reader, buf)
	if err != nil {
		return fmt.Errorf("read header failed:%w", err)
	}
	ver, cmd, atyp := buf[0], buf[1], buf[3]
	if ver != socks5Ver {
		return fmt.Errorf("not supported ver:%v", ver)
	}
	if cmd != cmdBind {
		return fmt.Errorf("not supported cmd:%v", cmd)
	}
	addr := ""
	switch atyp {
	case atypeIPV4:
		_, err = io.ReadFull(reader, buf)
		if err != nil {
			return fmt.Errorf("read atyp failed:%w", err)
		}
		addr = fmt.Sprintf("%d.%d.%d.%d", buf[0], buf[1], buf[2], buf[3])
	case atypeHOST:
		hostSize, err := reader.ReadByte()
		if err != nil {
			return fmt.Errorf("read hostSize failed:%w", err)
		}
		host := make([]byte, hostSize)
		_, err = io.ReadFull(reader, host)
		if err != nil {
			return fmt.Errorf("read host failed:%w", err)
		}
		addr = string(host)
	case atypeIPV6:
		return errors.New("IPv6: no supported yet")
	default:
		return errors.New("invalid atyp")
	}
	_, err = io.ReadFull(reader, buf[:2])
	if err != nil {
		return fmt.Errorf("read port failed:%w", err)
	}
	port := binary.BigEndian.Uint16(buf[:2])

	dest, err := net.Dial("tcp", fmt.Sprintf("%v:%v", addr, port))
	if err != nil {
		return fmt.Errorf("dial dst failed:%w", err)
	}
	defer dest.Close()
	log.Println("dial", addr, port)

	// +----+-----+-------+------+----------+----------+
	// |VER | REP |  RSV  | ATYP | BND.ADDR | BND.PORT |
	// +----+-----+-------+------+----------+----------+
	// | 1  |  1  | X'00' |  1   | Variable |    2     |
	// +----+-----+-------+------+----------+----------+
	// VER socks版本,这里为0x05
	// REP Relay field,内容取值如下 X'00' succeeded
	// RSV 保留字段
	// ATYPE 地址类型
	// BND.ADDR 服务绑定的地址
	// BND.PORT 服务绑定的端口DST.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)
	}
	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
}

connect 函数中,我们增加了以下功能:

  1. 使用 net.Dial 函数连接到目标服务器 dest,即客户端所请求的远程服务器。这样就建立了代理服务器与目标服务器之间的连接。
  2. 创建了一个 context.Context 对象 ctx 以及对应的取消函数 cancel,用于在后续的并发处理中跟踪数据转发的状态和控制。
  3. 使用两个并发的Go协程来进行数据转发:
    • 第一个Go协程将客户端发送的数据从 reader 拷贝到目标服务器 dest,实现代理服务器到目标服务器的数据转发。
    • 第二个Go协程将目标服务器发送的数据从 dest 拷贝回客户端 conn,实现目标服务器到代理服务器的数据转发。
  4. 使用 <-ctx.Done() 阻塞等待,直到两个数据转发的Go协程都完成后,函数返回。

现在,这个Socks5代理服务器已经可以实现基本的代理功能,能够处理客户端的连接请求,并将数据在代理服务器和目标服务器之间进行转发。

相关推荐
CallBack8 个月前
Typora+PicGo+阿里云OSS搭建个人图床,纵享丝滑!
前端·青训营笔记
Taonce1 年前
站在Android开发者的角度认识MQTT - 源码篇
android·青训营笔记
AB_IN1 年前
打开抖音会发生什么 | 青训营
青训营笔记
monster1231 年前
结营感受(go) | 青训营
青训营笔记
翼同学1 年前
实践记录:使用Bcrypt进行密码安全性保护和验证 | 青训营
青训营笔记
hu1hu_1 年前
Git 的正确使用姿势与最佳实践(1) | 青训营
青训营笔记
星曈1 年前
详解前端框架中的设计模式 | 青训营
青训营笔记
tuxiaobei1 年前
文件上传漏洞 Upload-lab 实践(中)| 青训营
青训营笔记
yibao1 年前
高质量编程与性能调优实战 | 青训营
青训营笔记
小金先生SG1 年前
阿里云对象存储OSS使用| 青训营
青训营笔记