猜谜游戏
该小项目是字节跳动青训营GO入门课程中的实战项目juejin.cn/course/byte…
需求分析
- 程序生成0-100随机整数
- 玩家每次需要输入一个猜测数字
- 程序需要告诉玩家该猜测数字是高于还是低于谜底随机数
- 玩家经过反复猜测,若猜对则玩家游戏胜利,退出程序
实现思路
- 生成随机数
在生成随机数时,除了使用时间戳初始化种子,还可以考虑使用更复杂的随机数生成算法,以提高随机性。
// 生成随机数v1版本
maxNum := 100
secretNum := rand.Intn(maxNum)
fmt.Println("The secret number is: ", secretNum)
需要用程序启动的时间戳来初始化随机数种子,不然生成的随机数会相同
maxNum := 100
rand.Seed(time.Now().UnixNano()) // 将程序启动的时间戳来初始化随机数种子
secretNum := rand.Intn(maxNum)
fmt.Println("The secret number is: ", secretNum)
- 读取用户输入
使用os库来控制输入,需要将输入转化成只读的流,这样才有更多的操作手段,可以从流中读取一行,但是每次读取行的末尾会多出一个换行符,需要单独删除该换行符,最后需要将该流转成一个数字,这样才最终得到用户输入的数字
使用bufio.NewReader(os.Stdin)来创建一个bufio.Reader对象,它是一个包装了io.Reader的缓冲区读取器,input, err := reader.ReadString('\n')这行代码调用了bufio.Reader的ReadString方法,该方法从缓冲区读取数据直到遇到指定的字符。
maxNum := 100
rand.Seed(time.Now().UnixNano()) // 用程序启动的时间戳来初始化随机数种子
secretNum := rand.Intn(maxNum) // 生成一个随机数
fmt.Println("The secret number is: ", secretNum)
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.TrimSuffix(input, "\r\n") // 去除换行符,这里注意用户输入的enter一般是\r\n
guess, err := strconv.Atoi(input) // 将用户输入的字符串转换为整数
if err != nil {
fmt.Println("Invaild input, please enter an integer values", err)
return
}
fmt.Println("You guessed is: ", guess)
这里需要注意的是,'\r'是回车,'\n'是换行,前者使光标到行首,后者使光标下移一格。通常用的Enter是两个加起来
当然这里也可以直接使用scanf来读取用户输入,这样的话就需要先声明存放用户输入的变量,然后在scanln中将该变量的地址传进去,直到用户输入回车,读取结束
var guess int
fmt.Scanln(&guess)
fmt.Println("You guessed is: ", guess)
- 实现判断逻辑
比较用户输入和随机数的大小,如果用户输入大于随机数,则提示用户猜大了,如果用户输入小于随机数,则提示用户猜小了,如果用户输入等于随机数,则提示用户猜对了,退出程序
在用户输入错误时,提供更详细的错误信息,并指导用户如何正确输入。
if guess > secretNum {
fmt.Println("You guess is bigger than the secret number, please try again")
} else if guess < secretNum {
fmt.Println("You guess is smaller than the secret number, please try again")
} else {
fmt.Println("Correct, you win!")
}
- 实现游戏循环
为了让游戏可以一直进行,需要将判断逻辑和读取用户输入的逻辑放在一个循环中,每次循环读取用户输入,然后判断用户输入是否正确,如果正确则退出循环,如果不正确则继续循环
func main() {
maxNum := 100
rand.Seed(time.Now().UnixNano()) // 用程序启动的时间戳来初始化随机数种子
secretNum := rand.Intn(maxNum) // 生成一个随机数
// fmt.Println("The secret number is: ", secretNum)
for {
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)
continue
}
input = strings.TrimSuffix(input, "\r\n") // 去除换行符,这里注意用户输入的enter一般是\r\n
guess, err := strconv.Atoi(input) // 将用户输入的字符串转换为整数
if err != nil {
fmt.Println("Invaild input, please enter an integer values", err)
continue
}
fmt.Println("You guessed is: ", guess)
if guess > secretNum {
fmt.Println("You guess is bigger than the secret number, please try again")
} else if guess < secretNum {
fmt.Println("You guess is smaller than the secret number, please try again")
} else {
fmt.Println("Correct, you win!")
break
}
}
}
总结
通过这个实战项目,我学习到以下的内容
-
随机数生成:理解了随机数生成的原理和如何使用时间戳来初始化随机数种子,以确保每次运行程序时生成的随机数都不同。
-
缓冲区读取:掌握了如何使用
bufio.Reader来高效地从标准输入读取数据,并且学会了如何处理换行符等特殊字符。 -
字符串处理:学习了如何使用
strings包中的TrimSuffix函数来处理字符串,去除不需要的后缀。 -
类型转换:了解了如何使用
strconv包将字符串转换为整数,并且如何处理转换过程中可能出现的错误。 -
循环和条件语句:通过实现游戏循环,加深了对
for循环和if-else条件语句的理解。 -
代码封装:认识到了将代码分解成函数的重要性,这有助于提高代码的可读性和可维护性。
-
错误处理:学会了在程序中添加错误处理逻辑,以提高程序的健壮性。
-
用户交互:通过实现用户输入和反馈,提高了对用户交互流程的设计和实现能力。
-
游戏设计:通过这个项目,我学会了如何设计一个简单的游戏,包括游戏规则的制定、用户交互的实现以及游戏循环的控制。
-
软件工程实践:这个项目也让我实践了软件工程的一些基本概念,如模块化设计、代码复用、测试和调试,这些都是软件开发中的重要技能。
在线词典
该小项目是字节跳动青训营GO入门课程中的实战项目juejin.cn/course/byte…
需求分析
-
执行程序时,在命令行传入单词
-
根据在线词典给出该单词的音标和注释————利用第三方api进行查询
项目亮点
-
使用http发送请求,解析json
-
使用代码生成提升开发效率
实现思路
- 抓包
可以通过浏览器开发者工具查看浏览器和服务器之间的请求响应,彩云小译的翻译请求接口为https://api.interpreter.caiyunai.com/v1/dict,这里要注意这个接口还有一个OPTIONS类型的请求,我们要选择的是POST类型的请求,从负载中可以看到该请求有两个json格式的参数,source表示待翻译的英文,trans_type表示要从哪种语言翻译为哪种语言,从响应中可以看到该请求响应的json格式数据
- 代码生成构建请求
由于请求非常复杂,用代码构建的话非常麻烦,可以使用代码生成方式来构建请求,首先需要右键请求复制为cURL(bash),于是就得到下面这一段bash命令
curl 'https://api.interpreter.caiyunai.com/v1/dict' \
-H 'accept: application/json, text/plain, */*' \
-H 'accept-language: zh' \
-H 'app-name: xiaoyi' \
-H 'authorization: Bearer' \
-H 'content-type: application/json;charset=UTF-8' \
-H 'device-id: 5a1713039eefd90fca8064c503d00a26' \
-H 'origin: https://fanyi.caiyunapp.com' \
-H 'os-type: web' \
-H 'os-version;' \
-H 'priority: u=1, i' \
-H 'referer: https://fanyi.caiyunapp.com/' \
-H 'sec-ch-ua: "Microsoft Edge";v="131", "Chromium";v="131", "Not_A Brand";v="24"' \
-H 'sec-ch-ua-mobile: ?0' \
-H 'sec-ch-ua-platform: "Windows"' \
-H 'sec-fetch-dest: empty' \
-H 'sec-fetch-mode: cors' \
-H 'sec-fetch-site: cross-site' \
-H 'user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0' \
-H 'x-authorization: token:qgemv4jr1y38jyq6vhvi' \
--data-raw '{"trans_type":"en2zh","source":"good"}'
然后就可以到代码生成网站https://curlconverter.com/go/将上面的bash输入之后就可以得到使用go语言来编写得到的请求了,极大的减少了构建http请求的工作量
创建请求的时候第三个参数需要为一个流,因此需要使用strings.NewReader({"trans_type":"en2zh","source":"good"})将字符串转换成流,这是因为body可能是一个很大的字符串,如果直接使用字符串的话会导致非常大的内存开销,因此使用流来传输数据,这样就可以占用很少的内存,然后流式创建请求
响应的body同样是一个流,在go中,为了避免资源泄露,需要加一个defer来手动关闭这个流
defer:会在函数结束之后,从下往上触发
package main
import (
"fmt"
"io"
"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("accept", "application/json, text/plain, */*")
req.Header.Set("accept-language", "zh")
req.Header.Set("app-name", "xiaoyi")
req.Header.Set("authorization", "Bearer")
req.Header.Set("content-type", "application/json;charset=UTF-8")
req.Header.Set("device-id", "5a1713039eefd90fca8064c503d00a26")
req.Header.Set("origin", "https://fanyi.caiyunapp.com")
req.Header.Set("os-type", "web")
req.Header.Set("os-version", "")
req.Header.Set("priority", "u=1, i")
req.Header.Set("referer", "https://fanyi.caiyunapp.com/")
req.Header.Set("sec-ch-ua", `"Microsoft Edge";v="131", "Chromium";v="131", "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/131.0.0.0 Safari/537.36 Edg/131.0.0.0")
req.Header.Set("x-authorization", "token:qgemv4jr1y38jyq6vhvi")
// 发起请求
resp, err := client.Do(req)
if err != nil {
log.Fatal(err)
}
// 关闭流
defer resp.Body.Close()
// 读取响应
bodyText, err := io.ReadAll(resp.Body)
if err != nil {
log.Fatal(err)
}
fmt.Printf("%s\n", bodyText)
}
- 生成request body
上面的请求中,body的数据是固定的,而我们需要用一个变量来作为body的输入,因此需要用json序列化
json序列化:需要构造一个结构体,使得其字段和json字段一一对应,然后直接调用json.Marshal即可
type DictRequest struct {
TransType string `json:"trans_type"`
Source string `json:"source"`
UserID string `json:"user_id"`
}
request := DictRequest{
TransType: "en2zh",
Source: "good",
}
// 序列化结构体,变成一个buf数组
buf, err := json.Marshal(request)
if err != nil {
log.Fatal(err)
}
var data = bytes.NewReader(buf)
- 解析response body
从响应中提取指定的字段,go是一门强类型的语言,在js或python等脚本语言中,这个body返回的是一个字典或者叫map的结构,可以直接用[]加点去取值,但是这不是go中的最佳实践,最常见的是写一个结构体,字段一一对应,然后反序列化到结构体中,但是通常返回的字段非常复杂,这种实现非常容易出错,因此可以继续使用代码生成的方法,使用在线网站https://mholt.github.io/json-to-go/即可自动生成对应的结构体
type DictResponse 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 []any `json:"related"`
Source string `json:"source"`
} `json:"dictionary"`
}
// 解析response
var dictResponse DictResponse
err = json.Unmarshal(bodyText, &dictResponse)
if err != nil {
log.Fatal(err)
}
fmt.Printf("%#v\n", dictResponse)
- 打印结果
结构体很大,其中只有几个是我们所需要的,因此我们选择性的将结构体中的翻译和解释打印
fmt.Println(request.Source, "UK: ", dictResponse.Dictionary.Prons.En, "US: ", dictResponse.Dictionary.Prons.EnUs)
for _, item := range dictResponse.Dictionary.Explanations {
fmt.Println(item)
}
- 完善代码
将在线字典的功能独立成一个函数,然后在main函数中调用,并且将命令行参数传入
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)
}
总结
通过这个实战项目,我学习到以下的内容:
-
HTTP请求构建:使用Go语言的标准库
net/http来构建HTTP请求,包括设置请求头、发送请求和接收响应。 -
JSON序列化与反序列化:学习了如何使用
encoding/json包来序列化Go结构体为JSON格式,以及如何将JSON响应反序列化为Go结构体。 -
命令行参数处理:通过
os和flag包,学习了如何从命令行接收参数,并根据参数执行相应的程序逻辑。 -
错误处理:在网络请求和文件操作中,学习了如何恰当地处理可能出现的错误。
-
代码生成工具的使用:利用在线工具如
curlconverter.com和json-to-go,提高了开发效率,减少了手动编写请求和解析JSON的复杂性。 -
API文档阅读:通过阅读彩云小译API文档,学习了如何根据API规范构建请求和解析响应。
-
资源管理:学习了使用
defer关键字来确保资源(如网络连接、文件句柄)在使用后能够被正确关闭。 -
软件设计原则:通过将在线词典功能封装成函数,学习了模块化设计的重要性,以及如何提高代码的可读性和可维护性。
SOCKS5代理服务器
该小项目是字节跳动青训营GO入门课程中的实战项目juejin.cn/course/byte…
SOCKS5介绍
SOCKS5协议都是明文协议,无法用来翻墙
若企业为了确保内网安全性,配置了很严格的防火墙策略,副作用就是访问内网中的资源会变得很麻烦,而SOCKS5协议相当于在防火墙上开了个口子,让授权的用户可以通过单个端口访问内部的所有资源
浏览器首先要跟SOCKS5代理服务器建立连接,再由代理服务器去和真正的服务器建立TCP连接
第一个阶段:协商阶段(协议版本号等信息)
第二个阶段:认证阶段(本项目不涉及,因为实现的是一个不加密的代理服务器)
第三个阶段:请求阶段
第四个阶段:relay阶段,代理服务器简单的将响应转发给浏览器,不关心流量的细节,因此流量可以是http、tcp等流量
实现思路
- 构建一个简单的TCP echo server,用来测试编写的代理服务是否正确
该代理服务器功能简单,即发送啥就回复啥,利用goroutine开子线程处理,开销比操作系统子线程子进程少很多,可以轻松的处理上万的并发,这也是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) {
// 表示在函数退出的时候1一定要把连接关掉,因为该连接的生命周期就是整个函数的生命周期
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
}
}
}
- 认证阶段
认证流程,首先浏览器会给服务器发送一个报文,第一个字段是version协议版本号,固定是5
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: %v", err)
}
if ver != socks5Ver {
return fmt.Errorf("not support ver: %v", ver)
}
methodSize, err := reader.ReadByte()
if err != nil {
return fmt.Errorf("read methodSize failed: %v", err)
}
method := make([]byte, methodSize)
_, err = io.ReadFull(reader, method)
if err != nil {
return fmt.Errorf("read method failed: %v", err)
}
log.Println("ver", ver, "method", method)
// +----+--------+
// |VER | METHOD |
// +----+--------+
// | 1 | 1 |
// +----+--------+
_, err = conn.Write([]byte{socks5Ver, 0x00})
if err != nil {
return fmt.Errorf("write falied: %v", err)
}
return nil
}
- 请求阶段
因为前四个字段长度相同,所以一次性读取,用4个字节的缓冲区,用ReadFull一下子填满,从而可以读取到这4个字节,然后逐个验证合法性
也就是按协议的字段定义规则,把字段都读取,然后进行验证分析,最后能够得到对应的IP和端口字段
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: %v", err)
}
ver, cmd, atyp := buf[0], buf[1], buf[3]
if ver != socks5Ver {
return fmt.Errorf("not support ver: %v", ver)
}
if cmd != cmdBind {
return fmt.Errorf("not support cmd: %v", cmd)
}
addr := ""
switch atyp {
case atypIPv4:
_, err = io.ReadFull(reader, buf)
if err != nil {
return fmt.Errorf("read atyp failed: %v", 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: %v", err)
}
// 按域名长度读取
host := make([]byte, hostSize)
_, err = io.ReadFull(reader, host)
if err != nil {
return fmt.Errorf("read host failed: %v", err)
}
addr = string(host)
case atypeIPv6:
return errors.New("IPv6: no supported yet")
default:
return errors.New("invaild atyp")
}
// 复用前面四字节的缓冲区buf,用切片语法裁剪成2字节的缓冲区
_, err = io.ReadFull(reader, buf[:2])
if err != nil {
return fmt.Errorf("read port failed: %v", 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 服务绑定的地址,四个字节,需要四个0
// BND.PORT 服务绑定的端口DST.PORT,两个字节,需要两个0
_, err = conn.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
if err != nil {
return fmt.Errorf("write failed: %v", err)
}
return nil
}
- relay阶段
本阶段代理服务器会与真正的服务器建立tcp连接,需要建立浏览器和下游服务器的双向数据转换,io库中的Copy函数可以实现单向数据转化func Copy(dst Write, src Reader) (written int64, err error)会将src只读流中的数据用一个死循环逐步的拷贝到dst这个可写流中
这里需要启动两个协程,一个负责读取浏览器发来的数据,另一个负责读取下游服务器发来的数据,然后通过io库的Copy函数实现双向数据转换
需要等待任何一个方向的copy失败,即某一方关闭连接,才能终止整个连接
dest, err := net.Dial("tcp", fmt.Sprintf("%v:%v", addr, port))
if err != nil {
return fmt.Errorf("dial failed: %v", err)
}
defer dest.Close()
ctx, concel := context.WithCancel(context.Background())
defer concel()
go func() {
_, _ = io.Copy(dest, reader)
concel()
}()
go func() {
_, _ = io.Copy(conn, dest)
concel()
}()
<-ctx.Done()
总结
通过这个实战项目,我学习到以下的内容:
-
SOCKS5协议深入理解:
- 通过实现SOCKS5代理服务器,我们深入理解了SOCKS5协议的四个阶段:协商阶段、认证阶段(本项目未涉及)、请求阶段和中继阶段。在代码中,我们实现了
auth函数来处理协商阶段,其中包含了对协议版本号的检查和方法的选择。
- 通过实现SOCKS5代理服务器,我们深入理解了SOCKS5协议的四个阶段:协商阶段、认证阶段(本项目未涉及)、请求阶段和中继阶段。在代码中,我们实现了
-
Go语言并发模型:
- 利用Go语言的goroutine,我们实现了并发处理客户端连接。在
main函数中,我们为每个接受的客户端连接启动了一个新的goroutine,使用go process(client)来处理,这展示了Go语言并发模型的高效性。
- 利用Go语言的goroutine,我们实现了并发处理客户端连接。在
-
网络编程实践:
- 通过
net包的使用,我们加深了对网络编程的理解。在process函数中,我们使用conn.Read和conn.Write方法进行数据传输,这包括了TCP连接的建立、数据传输和断开连接等操作。
- 通过
-
bufio库的深入应用:
- 在处理网络流时,我们熟练运用了
bufio库进行高效的读写操作。在auth函数中,我们使用bufio.NewReader来读取客户端发送的认证请求,并使用io.ReadFull来确保读取完整的数据。
- 在处理网络流时,我们熟练运用了
-
用户认证机制:
- 虽然本项目未涉及用户认证,但我们理解了SOCKS5协议中用户认证机制的重要性。在实际应用中,我们可以在
auth函数中增加对用户名和密码的验证,以增强代理服务器的安全性。
- 虽然本项目未涉及用户认证,但我们理解了SOCKS5协议中用户认证机制的重要性。在实际应用中,我们可以在
-
日志记录的重要性:
- 通过在代码中使用
log.Printf来记录关键操作和错误信息,我们认识到了日志记录在监控代理服务器运行状态和用户行为中的重要性。
- 通过在代码中使用